@zhuxixi/pi-agent-board 0.3.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/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch dialog helpers: resolve cwd suggestions, scoped model choices, and thinking options.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** @typedef {"off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"} ThinkingLevel */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} LaunchModelLike
|
|
12
|
+
* @property {string} provider
|
|
13
|
+
* @property {string} id
|
|
14
|
+
* @property {string=} name
|
|
15
|
+
* @property {boolean=} reasoning
|
|
16
|
+
* @property {Partial<Record<ThinkingLevel, string|null>>=} thinkingLevelMap
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} LaunchModelChoice
|
|
21
|
+
* @property {LaunchModelLike} model
|
|
22
|
+
* @property {ThinkingLevel=} thinkingLevel
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} LaunchContext
|
|
27
|
+
* @property {LaunchModelChoice[]} choices
|
|
28
|
+
* @property {LaunchModelLike|null} selectedModel
|
|
29
|
+
* @property {ThinkingLevel} thinking
|
|
30
|
+
* @property {ThinkingLevel[]} thinkingOptions
|
|
31
|
+
* @property {"scoped"|"all"} scopeSource
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const THINKING_LEVELS = /** @type {const} */ (["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} value @returns {value is ThinkingLevel} */
|
|
37
|
+
export function isThinkingLevel(value) {
|
|
38
|
+
return typeof value === "string" && THINKING_LEVELS.includes(/** @type {ThinkingLevel} */ (value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {LaunchModelLike|null|undefined} model */
|
|
42
|
+
export function canonicalModelRef(model) {
|
|
43
|
+
return model ? `${model.provider}/${model.id}` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {LaunchModelLike|null|undefined} a @param {LaunchModelLike|null|undefined} b */
|
|
47
|
+
export function sameModel(a, b) {
|
|
48
|
+
return Boolean(a && b && a.provider === b.provider && a.id === b.id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {LaunchModelLike|null|undefined} model @returns {ThinkingLevel[]} */
|
|
52
|
+
export function supportedThinkingLevels(model) {
|
|
53
|
+
if (!model?.reasoning) return ["off"];
|
|
54
|
+
const map = model.thinkingLevelMap ?? {};
|
|
55
|
+
const levels = THINKING_LEVELS.filter((level) => map[level] !== null);
|
|
56
|
+
return levels.length ? levels : [...THINKING_LEVELS];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {LaunchModelLike|null|undefined} model
|
|
61
|
+
* @param {ThinkingLevel|undefined|null} requested
|
|
62
|
+
* @param {ThinkingLevel=} fallback
|
|
63
|
+
* @returns {ThinkingLevel}
|
|
64
|
+
*/
|
|
65
|
+
export function clampThinkingLevel(model, requested, fallback = "off") {
|
|
66
|
+
const supported = supportedThinkingLevels(model);
|
|
67
|
+
if (requested && supported.includes(requested)) return requested;
|
|
68
|
+
if (supported.includes(fallback)) return fallback;
|
|
69
|
+
return supported[0] ?? "off";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the model choices for a cwd using pi's scoped-model settings semantics.
|
|
74
|
+
* Falls back to all available models when no scoped models are configured or none resolve.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} cwd
|
|
77
|
+
* @param {LaunchModelLike[]} availableModels
|
|
78
|
+
* @param {LaunchModelLike|null|undefined} preferredModel
|
|
79
|
+
* @param {ThinkingLevel|undefined|null} preferredThinking
|
|
80
|
+
* @returns {LaunchContext}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveLaunchContext(cwd, availableModels, preferredModel, preferredThinking) {
|
|
83
|
+
const settings = SettingsManager.create(cwd);
|
|
84
|
+
const patterns = settings.getEnabledModels();
|
|
85
|
+
const scoped = resolveScopedModels(patterns, availableModels);
|
|
86
|
+
const fallbackChoices = sortModels(availableModels, preferredModel).map((model) => ({ model }));
|
|
87
|
+
const choices = scoped.length ? scoped : fallbackChoices;
|
|
88
|
+
const scopeSource = scoped.length ? "scoped" : "all";
|
|
89
|
+
const defaultThinking = normalizeThinking(settings.getDefaultThinkingLevel()) ?? preferredThinking ?? "off";
|
|
90
|
+
const selectedChoice = preferredModel ? choices.find((choice) => sameModel(choice.model, preferredModel)) : undefined;
|
|
91
|
+
const selectedModel = selectedChoice?.model ?? choices[0]?.model ?? preferredModel ?? null;
|
|
92
|
+
const thinkingBase = selectedChoice?.thinkingLevel ?? preferredThinking ?? defaultThinking;
|
|
93
|
+
const thinking = clampThinkingLevel(selectedModel, thinkingBase, defaultThinking);
|
|
94
|
+
return {
|
|
95
|
+
choices,
|
|
96
|
+
selectedModel,
|
|
97
|
+
thinking,
|
|
98
|
+
thinkingOptions: supportedThinkingLevels(selectedModel),
|
|
99
|
+
scopeSource,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string|undefined} value
|
|
105
|
+
* @returns {ThinkingLevel|undefined}
|
|
106
|
+
*/
|
|
107
|
+
function normalizeThinking(value) {
|
|
108
|
+
return isThinkingLevel(value) ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {LaunchModelLike[]} models @param {LaunchModelLike|null|undefined} current */
|
|
112
|
+
function sortModels(models, current) {
|
|
113
|
+
return [...models].sort((a, b) => {
|
|
114
|
+
const aCurrent = sameModel(a, current);
|
|
115
|
+
const bCurrent = sameModel(b, current);
|
|
116
|
+
if (aCurrent && !bCurrent) return -1;
|
|
117
|
+
if (!aCurrent && bCurrent) return 1;
|
|
118
|
+
const providerCmp = a.provider.localeCompare(b.provider);
|
|
119
|
+
return providerCmp !== 0 ? providerCmp : a.id.localeCompare(b.id);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {string|undefined} patterns @param {LaunchModelLike[]} _available */
|
|
124
|
+
function resolveScopedModels(patterns, _available) {
|
|
125
|
+
const available = Array.isArray(_available) ? _available : [];
|
|
126
|
+
if (!patterns?.length) return [];
|
|
127
|
+
/** @type {LaunchModelChoice[]} */
|
|
128
|
+
const scoped = [];
|
|
129
|
+
for (const rawPattern of patterns) {
|
|
130
|
+
const pattern = String(rawPattern || "").trim();
|
|
131
|
+
if (!pattern) continue;
|
|
132
|
+
if (hasGlob(pattern)) {
|
|
133
|
+
const colonIdx = pattern.lastIndexOf(":");
|
|
134
|
+
let globPattern = pattern;
|
|
135
|
+
/** @type {ThinkingLevel|undefined} */
|
|
136
|
+
let thinkingLevel;
|
|
137
|
+
if (colonIdx !== -1) {
|
|
138
|
+
const suffix = pattern.slice(colonIdx + 1);
|
|
139
|
+
if (isThinkingLevel(suffix)) {
|
|
140
|
+
thinkingLevel = suffix;
|
|
141
|
+
globPattern = pattern.slice(0, colonIdx);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const matches = available.filter((model) => matchGlob(`${model.provider}/${model.id}`, globPattern) || matchGlob(model.id, globPattern));
|
|
145
|
+
for (const model of matches) pushUnique(scoped, { model, thinkingLevel });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const parsed = parseModelPattern(pattern, available);
|
|
149
|
+
if (parsed.model) pushUnique(scoped, { model: parsed.model, thinkingLevel: parsed.thinkingLevel });
|
|
150
|
+
}
|
|
151
|
+
return scoped;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {LaunchModelChoice[]} choices @param {LaunchModelChoice} choice */
|
|
155
|
+
function pushUnique(choices, choice) {
|
|
156
|
+
if (!choices.find((entry) => sameModel(entry.model, choice.model))) choices.push(choice);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} pattern */
|
|
160
|
+
function hasGlob(pattern) {
|
|
161
|
+
return /[*?[]/.test(pattern);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string} text @param {string} glob */
|
|
165
|
+
function matchGlob(text, glob) {
|
|
166
|
+
try {
|
|
167
|
+
return new RegExp(`^${globToRegExp(glob)}$`, "i").test(text);
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {string} glob */
|
|
174
|
+
function globToRegExp(glob) {
|
|
175
|
+
let out = "";
|
|
176
|
+
for (let i = 0; i < glob.length; i++) {
|
|
177
|
+
const ch = glob[i];
|
|
178
|
+
if (ch === "*") {
|
|
179
|
+
out += ".*";
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (ch === "?") {
|
|
183
|
+
out += ".";
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === "[") {
|
|
187
|
+
const end = glob.indexOf("]", i + 1);
|
|
188
|
+
if (end > i) {
|
|
189
|
+
out += glob.slice(i, end + 1);
|
|
190
|
+
i = end;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
out += escapeRegExp(ch);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @param {string} text */
|
|
200
|
+
function escapeRegExp(text) {
|
|
201
|
+
return text.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @param {string} id */
|
|
205
|
+
function isAlias(id) {
|
|
206
|
+
if (id.endsWith("-latest")) return true;
|
|
207
|
+
return !/-\d{8}$/.test(id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {string} modelReference @param {LaunchModelLike[]} availableModels */
|
|
211
|
+
function findExactModelReferenceMatch(modelReference, availableModels) {
|
|
212
|
+
const trimmed = modelReference.trim();
|
|
213
|
+
if (!trimmed) return undefined;
|
|
214
|
+
const lower = trimmed.toLowerCase();
|
|
215
|
+
const canonicalMatches = availableModels.filter((model) => `${model.provider}/${model.id}`.toLowerCase() === lower);
|
|
216
|
+
if (canonicalMatches.length === 1) return canonicalMatches[0];
|
|
217
|
+
if (canonicalMatches.length > 1) return undefined;
|
|
218
|
+
const slashIndex = trimmed.indexOf("/");
|
|
219
|
+
if (slashIndex !== -1) {
|
|
220
|
+
const provider = trimmed.slice(0, slashIndex).trim();
|
|
221
|
+
const modelId = trimmed.slice(slashIndex + 1).trim();
|
|
222
|
+
if (provider && modelId) {
|
|
223
|
+
const providerMatches = availableModels.filter((model) => model.provider.toLowerCase() === provider.toLowerCase() && model.id.toLowerCase() === modelId.toLowerCase());
|
|
224
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
225
|
+
if (providerMatches.length > 1) return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const idMatches = availableModels.filter((model) => model.id.toLowerCase() === lower);
|
|
229
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @param {string} modelPattern @param {LaunchModelLike[]} availableModels */
|
|
233
|
+
function tryMatchModel(modelPattern, availableModels) {
|
|
234
|
+
const exact = findExactModelReferenceMatch(modelPattern, availableModels);
|
|
235
|
+
if (exact) return exact;
|
|
236
|
+
const lower = modelPattern.toLowerCase();
|
|
237
|
+
const matches = availableModels.filter((model) => model.id.toLowerCase().includes(lower) || model.name?.toLowerCase().includes(lower));
|
|
238
|
+
if (!matches.length) return undefined;
|
|
239
|
+
const aliases = matches.filter((model) => isAlias(model.id));
|
|
240
|
+
const dated = matches.filter((model) => !isAlias(model.id));
|
|
241
|
+
if (aliases.length) return aliases.sort((a, b) => b.id.localeCompare(a.id))[0];
|
|
242
|
+
return dated.sort((a, b) => b.id.localeCompare(a.id))[0];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @param {string} pattern @param {LaunchModelLike[]} availableModels */
|
|
246
|
+
function parseModelPattern(pattern, availableModels) {
|
|
247
|
+
const exact = tryMatchModel(pattern, availableModels);
|
|
248
|
+
if (exact) return { model: exact, thinkingLevel: undefined };
|
|
249
|
+
const lastColon = pattern.lastIndexOf(":");
|
|
250
|
+
if (lastColon === -1) return { model: undefined, thinkingLevel: undefined };
|
|
251
|
+
const prefix = pattern.slice(0, lastColon);
|
|
252
|
+
const suffix = pattern.slice(lastColon + 1);
|
|
253
|
+
if (isThinkingLevel(suffix)) {
|
|
254
|
+
const result = parseModelPattern(prefix, availableModels);
|
|
255
|
+
if (result.model) return { model: result.model, thinkingLevel: suffix };
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
return parseModelPattern(prefix, availableModels);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {string} input */
|
|
262
|
+
function expandHome(input) {
|
|
263
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
264
|
+
if (!home) return input;
|
|
265
|
+
if (input === "~") return home;
|
|
266
|
+
if (input.startsWith(`~${path.sep}`)) return path.join(home, input.slice(2));
|
|
267
|
+
return input;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @param {string} value @param {string} baseCwd */
|
|
271
|
+
export function resolveDirectoryValue(value, baseCwd) {
|
|
272
|
+
const raw = String(value || "").trim();
|
|
273
|
+
if (!raw) return existsDir(baseCwd) ? path.resolve(baseCwd) : null;
|
|
274
|
+
const expanded = expandHome(raw);
|
|
275
|
+
const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseCwd, expanded);
|
|
276
|
+
return existsDir(resolved) ? resolved : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {string} query
|
|
281
|
+
* @param {string} baseCwd
|
|
282
|
+
* @param {number=} limit
|
|
283
|
+
* @returns {string[]}
|
|
284
|
+
*/
|
|
285
|
+
export function listDirectorySuggestions(query, baseCwd, limit = 8) {
|
|
286
|
+
const raw = String(query || "").trim();
|
|
287
|
+
const expanded = expandHome(raw);
|
|
288
|
+
const resolved = raw ? (path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseCwd, expanded)) : path.resolve(baseCwd);
|
|
289
|
+
let searchDir = resolved;
|
|
290
|
+
let fragment = "";
|
|
291
|
+
if (!existsDir(resolved) || !raw.endsWith(path.sep)) {
|
|
292
|
+
searchDir = existsDir(resolved) ? resolved : path.dirname(resolved);
|
|
293
|
+
fragment = existsDir(resolved) ? "" : path.basename(resolved);
|
|
294
|
+
}
|
|
295
|
+
/** @type {string[]} */
|
|
296
|
+
const out = [];
|
|
297
|
+
if (existsDir(resolved)) out.push(path.resolve(resolved));
|
|
298
|
+
if (existsDir(searchDir)) {
|
|
299
|
+
const lowerFragment = fragment.toLowerCase();
|
|
300
|
+
const children = readdirSync(searchDir, { withFileTypes: true })
|
|
301
|
+
.filter((entry) => entry.isDirectory())
|
|
302
|
+
.map((entry) => entry.name)
|
|
303
|
+
.filter((name) => !lowerFragment || name.toLowerCase().includes(lowerFragment))
|
|
304
|
+
.sort((a, b) => a.localeCompare(b));
|
|
305
|
+
for (const name of children) out.push(path.join(searchDir, name));
|
|
306
|
+
}
|
|
307
|
+
return Array.from(new Set(out)).slice(0, Math.max(1, limit));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @param {string} dir */
|
|
311
|
+
function existsDir(dir) {
|
|
312
|
+
try {
|
|
313
|
+
return existsSync(dir) && statSync(dir).isDirectory();
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch a detached job-runner process for one run.
|
|
3
|
+
*
|
|
4
|
+
* The runner is a plain `.mjs` spawned with `node`, fully detached (its own process
|
|
5
|
+
* group, stdio ignored) so it survives the parent Pi reloading or exiting. The parent
|
|
6
|
+
* records the runner pid in `pid.json` and watches the store files the runner writes.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { atomicWriteJson } from "./atomic.mjs";
|
|
10
|
+
import { resolveNode } from "./invocation.mjs";
|
|
11
|
+
import * as P from "./paths.mjs";
|
|
12
|
+
import { writePid } from "./store.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {import("./types.mjs").RunConfig} RunConfig */
|
|
15
|
+
/** @typedef {import("./types.mjs").HostConfig} HostConfig */
|
|
16
|
+
/** @typedef {import("./types.mjs").TitleConfig} TitleConfig */
|
|
17
|
+
/** @typedef {import("./types.mjs").AutoStateConfig} AutoStateConfig */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} root
|
|
21
|
+
* @param {RunConfig} config
|
|
22
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
23
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function launchRun(root, config, opts) {
|
|
26
|
+
const runDir = P.runDir(root, config.viewId, config.runId);
|
|
27
|
+
const configPath = `${runDir}/config.json`;
|
|
28
|
+
atomicWriteJson(configPath, config);
|
|
29
|
+
|
|
30
|
+
const node = opts.node ?? resolveNode();
|
|
31
|
+
const child = spawn(node, [opts.runnerScript, configPath], {
|
|
32
|
+
cwd: config.cwd,
|
|
33
|
+
detached: true,
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
env: process.env,
|
|
36
|
+
});
|
|
37
|
+
child.unref();
|
|
38
|
+
|
|
39
|
+
const pid = child.pid ?? null;
|
|
40
|
+
// Record the *runner/monitor* pid for liveness polling (the worker pid is tracked
|
|
41
|
+
// inside status.json by the runner itself).
|
|
42
|
+
writePid(root, config.viewId, config.runId, pid);
|
|
43
|
+
return { pid, configPath };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Launch a detached PTY host for a view. The host owns a long-lived child Pi and
|
|
48
|
+
* exposes a JSONL control socket for attach/input/resize/terminate.
|
|
49
|
+
* @param {string} root
|
|
50
|
+
* @param {HostConfig} config
|
|
51
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
52
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function launchHost(root, config, opts) {
|
|
55
|
+
const configPath = P.hostConfigPath(root, config.viewId);
|
|
56
|
+
atomicWriteJson(configPath, config);
|
|
57
|
+
|
|
58
|
+
const node = opts.node ?? resolveNode();
|
|
59
|
+
const child = spawn(node, [opts.runnerScript, configPath], {
|
|
60
|
+
cwd: config.cwd,
|
|
61
|
+
detached: true,
|
|
62
|
+
stdio: "ignore",
|
|
63
|
+
env: process.env,
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
|
|
67
|
+
return { pid: child.pid ?? null, configPath };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Launch a detached title runner for a view. Best-effort only: it may update `meta.json`
|
|
72
|
+
* later with a short GPT-generated name derived from the initial task prompt.
|
|
73
|
+
* @param {string} root
|
|
74
|
+
* @param {TitleConfig} config
|
|
75
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
76
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
77
|
+
*/
|
|
78
|
+
export function launchTitle(root, config, opts) {
|
|
79
|
+
const configPath = P.titleConfigPath(root, config.viewId);
|
|
80
|
+
atomicWriteJson(configPath, config);
|
|
81
|
+
|
|
82
|
+
const node = opts.node ?? resolveNode();
|
|
83
|
+
const child = spawn(node, [opts.runnerScript, configPath], {
|
|
84
|
+
cwd: config.cwd,
|
|
85
|
+
detached: true,
|
|
86
|
+
stdio: "ignore",
|
|
87
|
+
env: process.env,
|
|
88
|
+
});
|
|
89
|
+
child.unref();
|
|
90
|
+
|
|
91
|
+
return { pid: child.pid ?? null, configPath };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Launch a detached auto-state classifier for a view. Best-effort: it may update
|
|
96
|
+
* state.json later with a model-refined terminal bucket.
|
|
97
|
+
* @param {string} root
|
|
98
|
+
* @param {AutoStateConfig} config
|
|
99
|
+
* @param {{ runnerScript: string, node?: string }} opts
|
|
100
|
+
* @returns {{ pid: number|null, configPath: string }}
|
|
101
|
+
*/
|
|
102
|
+
export function launchAutoState(root, config, opts) {
|
|
103
|
+
const configPath = P.autoStateConfigPath(root, config.viewId);
|
|
104
|
+
atomicWriteJson(configPath, config);
|
|
105
|
+
|
|
106
|
+
const node = opts.node ?? resolveNode();
|
|
107
|
+
const child = spawn(node, [opts.runnerScript, configPath], {
|
|
108
|
+
cwd: config.cwd,
|
|
109
|
+
detached: true,
|
|
110
|
+
stdio: "ignore",
|
|
111
|
+
env: process.env,
|
|
112
|
+
});
|
|
113
|
+
child.unref();
|
|
114
|
+
|
|
115
|
+
return { pid: child.pid ?? null, configPath };
|
|
116
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
|
|
3
|
+
* Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { ensureDir } from "./atomic.mjs";
|
|
8
|
+
import * as P from "./paths.mjs";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {string} lockPath
|
|
15
|
+
* @param {() => T} fn
|
|
16
|
+
* @param {{ staleMs?: number }} [opts]
|
|
17
|
+
* @returns {T}
|
|
18
|
+
*/
|
|
19
|
+
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
20
|
+
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
21
|
+
acquireLock(lockPath, staleMs);
|
|
22
|
+
try {
|
|
23
|
+
return fn();
|
|
24
|
+
} finally {
|
|
25
|
+
releaseLock(lockPath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @template T
|
|
31
|
+
* @param {string} root
|
|
32
|
+
* @param {string} viewId
|
|
33
|
+
* @param {string} name
|
|
34
|
+
* @param {() => T} fn
|
|
35
|
+
* @param {{ staleMs?: number }} [opts]
|
|
36
|
+
* @returns {T}
|
|
37
|
+
*/
|
|
38
|
+
export function withViewLockSync(root, viewId, name, fn, opts = {}) {
|
|
39
|
+
return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} lockPath @param {number} staleMs */
|
|
43
|
+
function acquireLock(lockPath, staleMs) {
|
|
44
|
+
ensureDir(path.dirname(lockPath));
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
while (true) {
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(lockPath);
|
|
49
|
+
writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
|
|
50
|
+
return;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
|
|
53
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
releaseLock(lockPath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} lockPath @param {number} staleMs */
|
|
62
|
+
function isLockStale(lockPath, staleMs) {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(lockPath)) return false;
|
|
65
|
+
const raw = readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
66
|
+
const owner = JSON.parse(raw);
|
|
67
|
+
return Date.now() - Number(owner.at ?? 0) > staleMs;
|
|
68
|
+
} catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {string} lockPath */
|
|
74
|
+
function releaseLock(lockPath) {
|
|
75
|
+
try {
|
|
76
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* best effort */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for the agent-board store.
|
|
3
|
+
*
|
|
4
|
+
* Every helper takes an explicit `root` so tests can point at a tmp dir.
|
|
5
|
+
* The live default is `~/.pi/agent/agent-board/` (override with $AGENT_BOARD_ROOT;
|
|
6
|
+
* legacy $AGENT_VIEW_ROOT is also honored for migration).
|
|
7
|
+
*/
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
/** @returns {string} the live store root (env override or ~/.pi/agent/agent-board). */
|
|
12
|
+
export function defaultRoot() {
|
|
13
|
+
if (process.env.AGENT_BOARD_ROOT) return path.resolve(process.env.AGENT_BOARD_ROOT);
|
|
14
|
+
if (process.env.AGENT_VIEW_ROOT) return path.resolve(process.env.AGENT_VIEW_ROOT);
|
|
15
|
+
return path.join(os.homedir(), ".pi", "agent", "agent-board");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string} root */
|
|
19
|
+
export const rosterPath = (root) => path.join(root, "roster.json");
|
|
20
|
+
/** @param {string} root */
|
|
21
|
+
export const launchPrefsPath = (root) => path.join(root, "launch-prefs.json");
|
|
22
|
+
/** @param {string} root */
|
|
23
|
+
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
24
|
+
|
|
25
|
+
/** @param {string} root */
|
|
26
|
+
export const viewsDir = (root) => path.join(root, "views");
|
|
27
|
+
/** @param {string} root @param {string} viewId */
|
|
28
|
+
export const viewDir = (root, viewId) => path.join(root, "views", viewId);
|
|
29
|
+
/** @param {string} root @param {string} viewId */
|
|
30
|
+
export const metaPath = (root, viewId) => path.join(viewDir(root, viewId), "meta.json");
|
|
31
|
+
/** @param {string} root @param {string} viewId */
|
|
32
|
+
export const statePath = (root, viewId) => path.join(viewDir(root, viewId), "state.json");
|
|
33
|
+
/** @param {string} root @param {string} viewId */
|
|
34
|
+
export const hostPath = (root, viewId) => path.join(viewDir(root, viewId), "host.json");
|
|
35
|
+
/** @param {string} root @param {string} viewId */
|
|
36
|
+
export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "host-config.json");
|
|
37
|
+
/** @param {string} root @param {string} viewId */
|
|
38
|
+
export const titleConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "title-config.json");
|
|
39
|
+
/** @param {string} root @param {string} viewId */
|
|
40
|
+
export const autoStateConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "auto-state-config.json");
|
|
41
|
+
/** @param {string} root @param {string} viewId */
|
|
42
|
+
export const controlSocketPath = (root, viewId) => path.join(viewDir(root, viewId), "control.sock");
|
|
43
|
+
/** @param {string} root @param {string} viewId */
|
|
44
|
+
export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId), "screen.log");
|
|
45
|
+
/** @param {string} root @param {string} viewId */
|
|
46
|
+
export const hostPidPath = (root, viewId) => path.join(viewDir(root, viewId), "host-pid.json");
|
|
47
|
+
/** @param {string} root @param {string} viewId */
|
|
48
|
+
export const runsDir = (root, viewId) => path.join(viewDir(root, viewId), "runs");
|
|
49
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
50
|
+
export const runDir = (root, viewId, runId) => path.join(runsDir(root, viewId), runId);
|
|
51
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
52
|
+
export const statusPath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "status.json");
|
|
53
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
54
|
+
export const eventsPath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "events.jsonl");
|
|
55
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
56
|
+
export const stdoutPath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "stdout.log");
|
|
57
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
58
|
+
export const stderrPath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "stderr.log");
|
|
59
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
60
|
+
export const pidPath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "pid.json");
|
|
61
|
+
/** @param {string} root @param {string} viewId */
|
|
62
|
+
export const diagnosticsPath = (root, viewId) => path.join(viewDir(root, viewId), "diagnostics.jsonl");
|
|
63
|
+
/** @param {string} root @param {string} viewId */
|
|
64
|
+
export const evidencePath = (root, viewId) => path.join(viewDir(root, viewId), "evidence.json");
|
|
65
|
+
/** @param {string} root @param {string} viewId */
|
|
66
|
+
export const viewEvidencePath = evidencePath;
|
|
67
|
+
/** @param {string} root @param {string} viewId */
|
|
68
|
+
export const followUpQueuePath = (root, viewId) => path.join(viewDir(root, viewId), "queue.json");
|
|
69
|
+
/** @param {string} root @param {string} viewId */
|
|
70
|
+
export const queuePath = followUpQueuePath;
|
|
71
|
+
/** @param {string} root @param {string} viewId */
|
|
72
|
+
export const steeringPath = (root, viewId) => path.join(viewDir(root, viewId), "steering.json");
|
|
73
|
+
/** @param {string} root @param {string} viewId @param {string} name */
|
|
74
|
+
export const viewLockPath = (root, viewId, name) => path.join(viewDir(root, viewId), `${name}.lock`);
|
|
75
|
+
/** @param {string} root @param {string} viewId @param {string} runId */
|
|
76
|
+
export const runEvidencePath = (root, viewId, runId) => path.join(runDir(root, viewId, runId), "evidence.json");
|
|
77
|
+
|
|
78
|
+
/** @param {string} root */
|
|
79
|
+
export const sessionsDir = (root) => path.join(root, "sessions");
|
|
80
|
+
/** @param {string} root @param {string} viewId */
|
|
81
|
+
export const sessionFilePath = (root, viewId) => path.join(sessionsDir(root), `${viewId}.jsonl`);
|
|
82
|
+
|
|
83
|
+
/** @param {string} root */
|
|
84
|
+
export const worktreesDir = (root) => path.join(root, "worktrees");
|
|
85
|
+
/** @param {string} root @param {string} viewId */
|
|
86
|
+
export const worktreePath = (root, viewId) => path.join(worktreesDir(root), viewId);
|
package/src/core/pid.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Process liveness checks. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `pid` refers to a live process.
|
|
5
|
+
* `process.kill(pid, 0)` throws ESRCH when the process is gone, and EPERM when it
|
|
6
|
+
* exists but we lack permission to signal it — EPERM still means "alive".
|
|
7
|
+
* @param {number|null|undefined} pid
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isAlive(pid) {
|
|
11
|
+
if (!pid || pid <= 0) return false;
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === "EPERM";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Try to terminate a process tree gently, then force after `graceMs`.
|
|
22
|
+
* Safe no-op if already dead.
|
|
23
|
+
* @param {number|null|undefined} pid
|
|
24
|
+
* @param {number} [graceMs]
|
|
25
|
+
*/
|
|
26
|
+
export function killProcess(pid, graceMs = 4000) {
|
|
27
|
+
if (!isAlive(pid)) return;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(/** @type {number} */ (pid), "SIGTERM");
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
if (isAlive(pid)) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(/** @type {number} */ (pid), "SIGKILL");
|
|
37
|
+
} catch {
|
|
38
|
+
/* ignore */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, graceMs).unref?.();
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight debounce for dashboard prewarm.
|
|
3
|
+
*
|
|
4
|
+
* Arrow-key navigation must move the selection and repaint immediately; host
|
|
5
|
+
* prewarm (which may spawn a PTY host and re-scan rows) is deferred so bursts
|
|
6
|
+
* of keypresses trigger exactly one prewarm for the final resting selection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {() => void} prewarm Invoked (with errors swallowed) once scheduling
|
|
11
|
+
* goes quiet for `delayMs`. Re-reads current state at fire time.
|
|
12
|
+
* @param {number} [delayMs=200]
|
|
13
|
+
* @returns {{ schedule: () => void, cancel: () => void }}
|
|
14
|
+
*/
|
|
15
|
+
export function createPrewarmScheduler(prewarm, delayMs = 200) {
|
|
16
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
17
|
+
let timer = null;
|
|
18
|
+
const fire = () => {
|
|
19
|
+
timer = null;
|
|
20
|
+
try {
|
|
21
|
+
prewarm();
|
|
22
|
+
} catch {
|
|
23
|
+
/* prewarm is best-effort; never break navigation */
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
schedule() {
|
|
28
|
+
if (timer !== null) clearTimeout(timer);
|
|
29
|
+
const pending = setTimeout(fire, delayMs);
|
|
30
|
+
// Never let a pending debounce hold the event loop open on exit.
|
|
31
|
+
if (typeof pending.unref === "function") pending.unref();
|
|
32
|
+
timer = pending;
|
|
33
|
+
},
|
|
34
|
+
cancel() {
|
|
35
|
+
if (timer !== null) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
timer = null;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|