@zhuxixi/pi-agent-board 0.3.0 → 0.4.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/README.md +3 -2
- package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +797 -0
- package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +576 -0
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +916 -0
- package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +182 -0
- package/docs/superpowers/plans/2026-08-22-stable-list-order.md +51 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +106 -0
- package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +109 -0
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +131 -0
- package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +80 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +28 -4
- package/src/commands/agent-board.ts +1 -0
- package/src/core/auto-state.mjs +62 -7
- package/src/core/cwd-stats.mjs +112 -0
- package/src/core/derive.mjs +4 -4
- package/src/core/events.mjs +3 -2
- package/src/core/launch-options.mjs +47 -0
- package/src/core/paths.mjs +2 -0
- package/src/core/pty-attach-jiggle-controller.mjs +112 -0
- package/src/core/pty-attach-jiggle-retry.mjs +29 -10
- package/src/core/pty-attach-render.mjs +21 -0
- package/src/core/rows.mjs +23 -8
- package/src/core/types.mjs +2 -2
- package/src/runtime/service.mjs +15 -1
- package/src/ui/dashboard.ts +64 -15
- package/src/ui/pty-attach.ts +26 -77
package/package.json
CHANGED
package/runner/job-runner.mjs
CHANGED
|
@@ -13,14 +13,14 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
14
14
|
import { createRunStatus, finalizeRun, projectViewState, reduceEvent } from "../src/core/events.mjs";
|
|
15
15
|
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
16
|
-
import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
|
|
16
|
+
import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
|
|
17
17
|
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
18
18
|
import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
|
|
19
19
|
import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
|
|
20
20
|
import { newRunId } from "../src/core/ids.mjs";
|
|
21
21
|
import { launchRun } from "../src/core/launch.mjs";
|
|
22
22
|
import * as P from "../src/core/paths.mjs";
|
|
23
|
-
import { readState, writeState, writeStatus } from "../src/core/store.mjs";
|
|
23
|
+
import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
|
|
24
24
|
import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
|
|
25
25
|
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
|
|
26
26
|
|
|
@@ -102,6 +102,19 @@ function main() {
|
|
|
102
102
|
dirty = false;
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Persist only if the user hasn't marked the row done manually since the last
|
|
107
|
+
* persist. projectViewState() overwrites the row state unconditionally, so a
|
|
108
|
+
* post-exit model pass must never persist its stale in-memory status over a
|
|
109
|
+
* fresh manual completion.
|
|
110
|
+
*/
|
|
111
|
+
const persistUnlessManual = (force = false) => {
|
|
112
|
+
const latestView = readState(root, viewId);
|
|
113
|
+
if (isManualCompletion(latestView)) return false;
|
|
114
|
+
persist(force);
|
|
115
|
+
return true;
|
|
116
|
+
};
|
|
117
|
+
|
|
105
118
|
const scheduleFlush = () => {
|
|
106
119
|
if (flushTimer) {
|
|
107
120
|
dirty = true;
|
|
@@ -206,12 +219,12 @@ function main() {
|
|
|
206
219
|
if (changed) {
|
|
207
220
|
finalizeEvidence(evidence, status, Date.now());
|
|
208
221
|
status.evidenceSummary = summarizeEvidence(evidence);
|
|
209
|
-
|
|
222
|
+
persistUnlessManual(true);
|
|
210
223
|
}
|
|
211
224
|
return maybeModelSummary(config, status);
|
|
212
225
|
})
|
|
213
226
|
.then((changed) => {
|
|
214
|
-
if (changed)
|
|
227
|
+
if (changed) persistUnlessManual(true);
|
|
215
228
|
})
|
|
216
229
|
.catch(() => {})
|
|
217
230
|
.finally(() => {
|
|
@@ -333,6 +346,13 @@ async function maybeModelAutoState(config, status, evidence) {
|
|
|
333
346
|
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
334
347
|
15000,
|
|
335
348
|
);
|
|
349
|
+
// Fresh read: the user may have marked the row done manually during the model
|
|
350
|
+
// call. completeView clears autoState in both state.json and status.json, so a
|
|
351
|
+
// manual completion is detectable here; applying the classification to the stale
|
|
352
|
+
// in-memory status would clobber the user's verdict.
|
|
353
|
+
const fresh = readStatus(config.root, config.viewId, config.runId);
|
|
354
|
+
if (!fresh || isManualCompletion(fresh)) return false;
|
|
355
|
+
Object.assign(status, fresh);
|
|
336
356
|
const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
337
357
|
const changed = applyAutoStateToStatus(status, classification, Date.now());
|
|
338
358
|
if (changed) {
|
|
@@ -366,6 +386,10 @@ async function maybeModelSummary(config, status) {
|
|
|
366
386
|
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
367
387
|
15000,
|
|
368
388
|
);
|
|
389
|
+
// The user may have marked the row done manually during the summary call.
|
|
390
|
+
// Updating the stale status and letting the caller persist would clobber the
|
|
391
|
+
// manual completion, so bail out before touching the in-memory status.
|
|
392
|
+
if (isManualCompletion(readState(config.root, config.viewId))) return false;
|
|
369
393
|
const text = out.trim().split("\n").slice(-1)[0]?.trim();
|
|
370
394
|
if (text) {
|
|
371
395
|
status.summary = text.replace(/^["']|["']$/g, "").slice(0, 80);
|
|
@@ -88,6 +88,7 @@ export async function openDashboard(
|
|
|
88
88
|
};
|
|
89
89
|
const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
|
|
90
90
|
service,
|
|
91
|
+
root: service.getRoot(),
|
|
91
92
|
defaultCwd: ctx.cwd,
|
|
92
93
|
initialSelectedId: options.initialSelectedId,
|
|
93
94
|
availableModels,
|
package/src/core/auto-state.mjs
CHANGED
|
@@ -40,6 +40,18 @@ function isOff(value) {
|
|
|
40
40
|
return typeof value === "string" && /^(0|false|off|no)$/i.test(value.trim());
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Whether automatic `done` classification is disabled.
|
|
45
|
+
* Default (env unset): disabled — completed is only ever set by the user.
|
|
46
|
+
* Set AGENT_BOARD_AUTO_STATE_NO_DONE to 0/false/off/no to restore auto-done.
|
|
47
|
+
* @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
|
|
48
|
+
*/
|
|
49
|
+
export function autoStateDoneDisabled(env = process.env) {
|
|
50
|
+
const raw = env.AGENT_BOARD_AUTO_STATE_NO_DONE;
|
|
51
|
+
if (typeof raw !== "string" || !raw.trim()) return true;
|
|
52
|
+
return !isOff(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
/** @param {AutoStateKind} kind @returns {SemanticState} */
|
|
44
56
|
export function semanticStateForAutoKind(kind) {
|
|
45
57
|
switch (kind) {
|
|
@@ -55,22 +67,35 @@ export function semanticStateForAutoKind(kind) {
|
|
|
55
67
|
/**
|
|
56
68
|
* Prompt used by runners for the cheap classifier pass.
|
|
57
69
|
* @param {string} latestAssistantText
|
|
70
|
+
* @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
|
|
58
71
|
*/
|
|
59
|
-
export function buildAutoStatePrompt(latestAssistantText) {
|
|
72
|
+
export function buildAutoStatePrompt(latestAssistantText, env = process.env) {
|
|
60
73
|
const text = truncate(String(latestAssistantText || "").trim(), 6000);
|
|
61
|
-
|
|
74
|
+
const doneLine = autoStateDoneDisabled(env)
|
|
75
|
+
? ""
|
|
76
|
+
: "- done: the requested work is complete, final answer given, no user input required.\n";
|
|
77
|
+
const doneNote = autoStateDoneDisabled(env)
|
|
78
|
+
? "The user marks completed manually in the dashboard, so completion signals (done/completed/finished) must be classified as in_progress.\n"
|
|
79
|
+
: "";
|
|
80
|
+
const states = autoStateDoneDisabled(env) ? "needs_input|in_progress" : "needs_input|in_progress|done";
|
|
81
|
+
return `Classify the LAST assistant response for a coding-agent dashboard.\n\nChoose exactly one state:\n- needs_input: the assistant asks the user for a decision, clarification, approval, credentials, or is blocked waiting for the user.\n- in_progress: work is partial, next steps remain, verification is pending/failed, or the assistant says it will continue later.\n${doneLine}${doneNote}\nReturn ONLY minified JSON with this shape:\n{"state":"${states}","confidence":"high|medium|low","reason":"short reason <=18 words","question":"user-facing question or null"}\n\nLast assistant response:\n${text}`;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/**
|
|
65
85
|
* Parse and normalize model JSON. Returns null if the response is unusable.
|
|
66
86
|
* @param {string} raw
|
|
67
|
-
* @param {{ latestAssistantText?: string, now?: number, lastAgentActivityAt?: number|null }} [opts]
|
|
87
|
+
* @param {{ latestAssistantText?: string, now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
|
|
68
88
|
*/
|
|
69
89
|
export function parseAutoStateModelOutput(raw, opts = {}) {
|
|
70
90
|
const obj = extractJsonObject(raw);
|
|
71
91
|
if (!obj) return null;
|
|
72
|
-
|
|
92
|
+
let kind = normalizeKind(obj.state ?? obj.kind ?? obj.status);
|
|
73
93
|
if (!kind) return null;
|
|
94
|
+
let downgraded = false;
|
|
95
|
+
if (kind === "done" && autoStateDoneDisabled(opts.env ?? process.env)) {
|
|
96
|
+
kind = "in_progress";
|
|
97
|
+
downgraded = true;
|
|
98
|
+
}
|
|
74
99
|
const confidence = normalizeConfidence(obj.confidence);
|
|
75
100
|
const latest = opts.latestAssistantText ?? "";
|
|
76
101
|
const nb = detectNeedsInput(latest);
|
|
@@ -78,7 +103,9 @@ export function parseAutoStateModelOutput(raw, opts = {}) {
|
|
|
78
103
|
return makeClassification(kind, {
|
|
79
104
|
source: "model",
|
|
80
105
|
confidence,
|
|
81
|
-
reason:
|
|
106
|
+
reason: downgraded
|
|
107
|
+
? "Model reported done but auto-done is disabled"
|
|
108
|
+
: cleanReason(obj.reason) || defaultReason(kind),
|
|
82
109
|
question,
|
|
83
110
|
now: opts.now,
|
|
84
111
|
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
@@ -90,7 +117,7 @@ export function parseAutoStateModelOutput(raw, opts = {}) {
|
|
|
90
117
|
* Conservative fallback classifier. It should be useful, but never clever enough to
|
|
91
118
|
* overrule strong question/error signals.
|
|
92
119
|
* @param {string} latestAssistantText
|
|
93
|
-
* @param {{ now?: number, lastAgentActivityAt?: number|null }} [opts]
|
|
120
|
+
* @param {{ now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
|
|
94
121
|
*/
|
|
95
122
|
export function heuristicAutoState(latestAssistantText, opts = {}) {
|
|
96
123
|
const text = String(latestAssistantText || "").trim();
|
|
@@ -110,7 +137,8 @@ export function heuristicAutoState(latestAssistantText, opts = {}) {
|
|
|
110
137
|
const lower = text.toLowerCase();
|
|
111
138
|
const pending = hasPendingSignal(lower);
|
|
112
139
|
const done = hasDoneSignal(lower);
|
|
113
|
-
|
|
140
|
+
const env = opts.env ?? process.env;
|
|
141
|
+
if (done && !pending && !autoStateDoneDisabled(env)) {
|
|
114
142
|
return makeClassification("done", {
|
|
115
143
|
source: "heuristic",
|
|
116
144
|
confidence: hasStrongDoneSignal(lower) ? "high" : "medium",
|
|
@@ -120,6 +148,18 @@ export function heuristicAutoState(latestAssistantText, opts = {}) {
|
|
|
120
148
|
latestAssistantText: text,
|
|
121
149
|
});
|
|
122
150
|
}
|
|
151
|
+
if (done && !pending) {
|
|
152
|
+
// Auto-done disabled: completion signals stay out of the completed bucket;
|
|
153
|
+
// the user marks completed manually from the dashboard.
|
|
154
|
+
return makeClassification("in_progress", {
|
|
155
|
+
source: "heuristic",
|
|
156
|
+
confidence: hasStrongDoneSignal(lower) ? "medium" : "low",
|
|
157
|
+
reason: "Assistant reported completion but auto-done is disabled",
|
|
158
|
+
now: opts.now,
|
|
159
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
160
|
+
latestAssistantText: text,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
123
163
|
if (pending) {
|
|
124
164
|
return makeClassification("in_progress", {
|
|
125
165
|
source: "heuristic",
|
|
@@ -150,6 +190,17 @@ export function autoStateFromModelOrHeuristic(modelOutput, latestAssistantText,
|
|
|
150
190
|
return parseAutoStateModelOutput(modelOutput, { latestAssistantText, ...opts }) ?? heuristicAutoState(latestAssistantText, opts);
|
|
151
191
|
}
|
|
152
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Whether a row/status shows a manual completion. completeView clears autoState on
|
|
195
|
+
* both state.json and status.json when the user marks done, so `autoState == null`
|
|
196
|
+
* combined with `completed` is the manual-completion signal. Auto-classified
|
|
197
|
+
* completed rows keep autoState and stay refinable by a later model pass.
|
|
198
|
+
* @param {{ semanticState?: string, autoState?: unknown|null }|null|undefined} state
|
|
199
|
+
*/
|
|
200
|
+
export function isManualCompletion(state) {
|
|
201
|
+
return Boolean(state && state.semanticState === "completed" && state.autoState == null);
|
|
202
|
+
}
|
|
203
|
+
|
|
153
204
|
/**
|
|
154
205
|
* Mutate a RunStatus with a classification. Returns true if state/metadata changed.
|
|
155
206
|
* @param {import("./types.mjs").RunStatus} status
|
|
@@ -159,6 +210,8 @@ export function autoStateFromModelOrHeuristic(modelOutput, latestAssistantText,
|
|
|
159
210
|
export function applyAutoStateToStatus(status, classification, now = Date.now()) {
|
|
160
211
|
if (!classification || status.processState === "alive") return false;
|
|
161
212
|
if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
213
|
+
// Manual completions are user verdicts and must never be overwritten.
|
|
214
|
+
if (isManualCompletion(status)) return false;
|
|
162
215
|
const nextState = semanticStateForAutoKind(classification.kind);
|
|
163
216
|
const before = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
|
|
164
217
|
status.semanticState = nextState;
|
|
@@ -182,6 +235,8 @@ export function applyAutoStateToStatus(status, classification, now = Date.now())
|
|
|
182
235
|
export function applyAutoStateToViewState(state, classification, now = Date.now()) {
|
|
183
236
|
if (!classification || state.processState === "alive") return false;
|
|
184
237
|
if (state.semanticState === "failed" || state.semanticState === "stopped") return false;
|
|
238
|
+
// Manual completions are user verdicts and must never be overwritten.
|
|
239
|
+
if (isManualCompletion(state)) return false;
|
|
185
240
|
const nextState = semanticStateForAutoKind(classification.kind);
|
|
186
241
|
const before = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
|
|
187
242
|
state.semanticState = nextState;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent cwd usage stats for the launch dialog's directory favorites.
|
|
3
|
+
*
|
|
4
|
+
* Counts every successfully dispatched session per cwd, independent of the
|
|
5
|
+
* view lifecycle (deleting a view does not decrement). The dashboard reads
|
|
6
|
+
* the ranked list for the cwd picker's favorites mode. Pure node, no Pi imports.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
11
|
+
import * as P from "./paths.mjs";
|
|
12
|
+
import { readMeta, readRoster } from "./store.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} CwdStatsEntry
|
|
16
|
+
* @property {number} count
|
|
17
|
+
* @property {number} lastUsed epoch ms, like meta.updatedAt
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
21
|
+
function emptyStats() {
|
|
22
|
+
return { version: 1, entries: {} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} root @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
26
|
+
export function readCwdStats(root) {
|
|
27
|
+
const raw = readJson(P.cwdStatsPath(root), null);
|
|
28
|
+
if (!raw || typeof raw !== "object" || typeof raw.entries !== "object" || raw.entries === null) return emptyStats();
|
|
29
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
30
|
+
const entries = {};
|
|
31
|
+
for (const [dir, entry] of Object.entries(raw.entries)) {
|
|
32
|
+
if (!entry || typeof entry.count !== "number") continue;
|
|
33
|
+
entries[dir] = {
|
|
34
|
+
count: Math.max(0, Math.floor(entry.count)),
|
|
35
|
+
lastUsed: typeof entry.lastUsed === "number" ? entry.lastUsed : 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { version: 1, entries };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One-time seed: aggregate cwd counts from every roster view's meta.json.
|
|
43
|
+
* No-op when cwd-stats.json already exists.
|
|
44
|
+
* @param {string} root
|
|
45
|
+
*/
|
|
46
|
+
export function seedCwdStatsFromViews(root) {
|
|
47
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
48
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
49
|
+
const entries = {};
|
|
50
|
+
for (const viewId of readRoster(root).views ?? []) {
|
|
51
|
+
const meta = readMeta(root, viewId);
|
|
52
|
+
const cwd = meta?.cwd;
|
|
53
|
+
if (!cwd) continue;
|
|
54
|
+
const lastUsed = typeof meta.updatedAt === "number" ? meta.updatedAt : Date.now();
|
|
55
|
+
const existing = entries[cwd];
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.count += 1;
|
|
58
|
+
existing.lastUsed = Math.max(existing.lastUsed, lastUsed);
|
|
59
|
+
} else {
|
|
60
|
+
entries[cwd] = { count: 1, lastUsed };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
atomicWriteJson(P.cwdStatsPath(root), { version: 1, entries });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Seed when the stats file is missing; tolerate every failure (dashboard UX
|
|
68
|
+
* must never break because of stats bookkeeping).
|
|
69
|
+
* @param {string} root
|
|
70
|
+
*/
|
|
71
|
+
export function ensureCwdStatsSeeded(root) {
|
|
72
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
73
|
+
try {
|
|
74
|
+
seedCwdStatsFromViews(root);
|
|
75
|
+
} catch {
|
|
76
|
+
/* best effort */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record one successful dispatch for `cwd`. Invalid dirs are ignored.
|
|
82
|
+
* @param {string} root @param {string} cwd
|
|
83
|
+
*/
|
|
84
|
+
export function recordCwdLaunch(root, cwd) {
|
|
85
|
+
if (!cwd || !existsSync(cwd)) return;
|
|
86
|
+
const stats = readCwdStats(root);
|
|
87
|
+
const existing = stats.entries[cwd] ?? { count: 0, lastUsed: 0 };
|
|
88
|
+
stats.entries[cwd] = { count: existing.count + 1, lastUsed: Date.now() };
|
|
89
|
+
atomicWriteJson(P.cwdStatsPath(root), stats);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ranked candidates: count desc, then lastUsed desc; home appended at the
|
|
94
|
+
* end when absent so the user's home dir is always one keystroke away.
|
|
95
|
+
* @param {string} root @param {number=} limit
|
|
96
|
+
* @returns {Array<{path: string, count: number}>}
|
|
97
|
+
*/
|
|
98
|
+
export function rankedCwdCandidates(root, limit = 8) {
|
|
99
|
+
const stats = readCwdStats(root);
|
|
100
|
+
const rows = Object.entries(stats.entries).map(([dir, entry]) => ({
|
|
101
|
+
path: dir,
|
|
102
|
+
count: entry.count,
|
|
103
|
+
lastUsed: entry.lastUsed,
|
|
104
|
+
}));
|
|
105
|
+
rows.sort((a, b) => b.count - a.count || b.lastUsed - a.lastUsed);
|
|
106
|
+
const out = rows.slice(0, Math.max(1, limit)).map(({ path, count }) => ({ path, count }));
|
|
107
|
+
const home = os.homedir();
|
|
108
|
+
if (out.some((entry) => entry.path === home)) return out;
|
|
109
|
+
const homeRow = rows.find((entry) => entry.path === home);
|
|
110
|
+
out.push(homeRow ? { path: home, count: homeRow.count } : { path: home, count: 0 });
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/src/core/derive.mjs
CHANGED
|
@@ -16,8 +16,8 @@ import { firstSentence, truncate } from "./heuristics.mjs";
|
|
|
16
16
|
const GENERIC_STATUS_TEXT = {
|
|
17
17
|
queued: new Set(["Queued"]),
|
|
18
18
|
working: new Set(["Working", "Working…", "Running", "Running…"]),
|
|
19
|
-
needs_input: new Set(["Needs input"]),
|
|
20
|
-
idle: new Set(["Idle", "In Progress"]),
|
|
19
|
+
needs_input: new Set(["Needs input", "Needs answer"]),
|
|
20
|
+
idle: new Set(["Idle", "In Progress", "Needs instructions"]),
|
|
21
21
|
completed: new Set(["Completed", "Done"]),
|
|
22
22
|
failed: new Set(["Failed"]),
|
|
23
23
|
stopped: new Set(["Stopped"]),
|
|
@@ -50,9 +50,9 @@ export function fallbackStatusText(state) {
|
|
|
50
50
|
case "working":
|
|
51
51
|
return "Running…";
|
|
52
52
|
case "needs_input":
|
|
53
|
-
return "Needs
|
|
53
|
+
return "Needs answer";
|
|
54
54
|
case "idle":
|
|
55
|
-
return "
|
|
55
|
+
return "Needs instructions";
|
|
56
56
|
case "completed":
|
|
57
57
|
return "Done";
|
|
58
58
|
case "failed":
|
package/src/core/events.mjs
CHANGED
|
@@ -122,14 +122,15 @@ export function reduceEvent(status, event, now, opts = {}) {
|
|
|
122
122
|
if (msg.errorMessage) status.error = msg.errorMessage;
|
|
123
123
|
else if (msg.stopReason === "stop") status.error = null;
|
|
124
124
|
const text = assistantText(msg);
|
|
125
|
+
let nb = { needsInput: false, question: null };
|
|
125
126
|
if (text) {
|
|
126
127
|
// Store the full latest text (truncated) so peek shows meaningful output;
|
|
127
128
|
// deriveSummary() condenses it to a first sentence for the row.
|
|
128
129
|
status.latestAssistantPreview = truncate(text, PREVIEW_MAX);
|
|
129
|
-
|
|
130
|
+
nb = detectNeedsInput(text);
|
|
130
131
|
status.question = nb.question;
|
|
131
132
|
}
|
|
132
|
-
status.semanticState = "working";
|
|
133
|
+
status.semanticState = nb.needsInput ? "needs_input" : "working";
|
|
133
134
|
preservePendingQuestion(status);
|
|
134
135
|
status.lastActivityAt = now;
|
|
135
136
|
status.lastAgentActivityAt = now;
|
|
@@ -315,3 +315,50 @@ function existsDir(dir) {
|
|
|
315
315
|
return false;
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Keep only candidates whose path is an existing directory.
|
|
321
|
+
* @param {CwdCandidate[]} candidates
|
|
322
|
+
* @returns {CwdCandidate[]}
|
|
323
|
+
*/
|
|
324
|
+
export function existingCwdCandidates(candidates) {
|
|
325
|
+
return candidates.filter((entry) => existsDir(entry.path));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @typedef {Object} CwdCandidate
|
|
330
|
+
* @property {string} path
|
|
331
|
+
* @property {number} count
|
|
332
|
+
*/
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Filter ranked cwd candidates by case-insensitive substring match anywhere
|
|
336
|
+
* in the path (empty query keeps the full ranked list).
|
|
337
|
+
* @param {CwdCandidate[]} candidates
|
|
338
|
+
* @param {string} query
|
|
339
|
+
* @returns {CwdCandidate[]}
|
|
340
|
+
*/
|
|
341
|
+
export function filterCwdCandidates(candidates, query) {
|
|
342
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
343
|
+
if (!q) return candidates;
|
|
344
|
+
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
349
|
+
* is empty or matches ranked candidates, filesystem browse otherwise.
|
|
350
|
+
* @param {string} query
|
|
351
|
+
* @param {CwdCandidate[]} ranked
|
|
352
|
+
* @param {string} baseCwd
|
|
353
|
+
* @returns {{mode: "favorites"|"browse", suggestions: string[]}}
|
|
354
|
+
*/
|
|
355
|
+
export function nextCwdPickerState(query, ranked, baseCwd) {
|
|
356
|
+
if (!ranked || ranked.length === 0) {
|
|
357
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
358
|
+
}
|
|
359
|
+
const matches = filterCwdCandidates(ranked, query);
|
|
360
|
+
if (String(query ?? "").trim() === "" || matches.length > 0) {
|
|
361
|
+
return { mode: "favorites", suggestions: matches.map((entry) => entry.path) };
|
|
362
|
+
}
|
|
363
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
364
|
+
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export const rosterPath = (root) => path.join(root, "roster.json");
|
|
|
21
21
|
export const launchPrefsPath = (root) => path.join(root, "launch-prefs.json");
|
|
22
22
|
/** @param {string} root */
|
|
23
23
|
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
24
|
+
/** @param {string} root */
|
|
25
|
+
export const cwdStatsPath = (root) => path.join(root, "cwd-stats.json");
|
|
24
26
|
|
|
25
27
|
/** @param {string} root */
|
|
26
28
|
export const viewsDir = (root) => path.join(root, "views");
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injectable orchestration for the attach jiggle-retry chain.
|
|
3
|
+
*
|
|
4
|
+
* Owns the retry state machine, backoff timers, cross-chunk scanning, and the
|
|
5
|
+
* one-shot re-arm on the child TUI's first frame (\x1b[?2026h). The attach
|
|
6
|
+
* component (and the cold-start E2E) drive it through injected callbacks, so
|
|
7
|
+
* the whole choreography is testable without a TUI or socket.
|
|
8
|
+
*
|
|
9
|
+
* Cold-start design (issue #10): the chain starts at socket connect, but a cold
|
|
10
|
+
* child pi-tui installs its SIGWINCH listener only ~5s in — every early jiggle
|
|
11
|
+
* is lost. When the first TUI frame arrives we re-arm once with a fresh budget,
|
|
12
|
+
* so the next jiggle lands on a live TUI and its fullRender emits \x1b[2J,
|
|
13
|
+
* which stops the chain. If pi-tui ever drops the 2026h sequence, this degrades
|
|
14
|
+
* to the plain connect-time chain (still better than the old one-shot).
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
advanceRetry,
|
|
18
|
+
createJiggleRetryState,
|
|
19
|
+
feedOutput,
|
|
20
|
+
nextRetryDelay,
|
|
21
|
+
stopRetry,
|
|
22
|
+
} from "./pty-attach-jiggle-retry.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} JiggleRetryControllerDeps
|
|
26
|
+
* @property {() => void} sendJiggle - Fire one resize jiggle at the child.
|
|
27
|
+
* @property {(fn: () => void, ms: number) => unknown} setTimeoutFn - Timer factory.
|
|
28
|
+
* @property {(timer: unknown) => void} clearTimeoutFn - Timer canceller.
|
|
29
|
+
* @property {() => boolean} [shouldFire] - Guard on retry fire; false stops the chain without firing.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {JiggleRetryControllerDeps} deps
|
|
34
|
+
*/
|
|
35
|
+
export function createJiggleRetryController(deps) {
|
|
36
|
+
const { sendJiggle, setTimeoutFn, clearTimeoutFn, shouldFire } = deps;
|
|
37
|
+
let state = createJiggleRetryState();
|
|
38
|
+
let carry = "";
|
|
39
|
+
let tuiFrameSeen = false;
|
|
40
|
+
/** @type {unknown | null} */
|
|
41
|
+
let timer = null;
|
|
42
|
+
|
|
43
|
+
function clearTimer() {
|
|
44
|
+
if (timer === null) return;
|
|
45
|
+
clearTimeoutFn(timer);
|
|
46
|
+
timer = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleNext() {
|
|
50
|
+
const delay = nextRetryDelay(state);
|
|
51
|
+
if (delay === null) {
|
|
52
|
+
state = stopRetry(state);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
timer = setTimeoutFn(() => {
|
|
56
|
+
timer = null;
|
|
57
|
+
if (shouldFire && !shouldFire()) {
|
|
58
|
+
state = stopRetry(state);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
sendJiggle();
|
|
62
|
+
state = advanceRetry(state);
|
|
63
|
+
scheduleNext();
|
|
64
|
+
}, delay);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reset everything (fresh connection) and schedule the first retry. */
|
|
68
|
+
function start() {
|
|
69
|
+
clearTimer();
|
|
70
|
+
state = createJiggleRetryState();
|
|
71
|
+
carry = "";
|
|
72
|
+
tuiFrameSeen = false;
|
|
73
|
+
scheduleNext();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Feed one socket output chunk. Clear detection wins over re-arm when both
|
|
78
|
+
* sequences appear in one chunk (a hot attach's first frame is often the
|
|
79
|
+
* fullRender we were waiting for). Re-arm fires at most once per start().
|
|
80
|
+
* @param {string} data
|
|
81
|
+
*/
|
|
82
|
+
function feed(data) {
|
|
83
|
+
if (state.clearDetected) return; // chain done; nothing left to detect
|
|
84
|
+
const result = feedOutput(state, data, carry);
|
|
85
|
+
state = result.state;
|
|
86
|
+
carry = result.carry;
|
|
87
|
+
if (result.clearFound) {
|
|
88
|
+
clearTimer();
|
|
89
|
+
state = stopRetry({ ...state, clearDetected: true });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (result.frameStartFound && !tuiFrameSeen && !state.clearDetected) {
|
|
93
|
+
tuiFrameSeen = true;
|
|
94
|
+
clearTimer();
|
|
95
|
+
state = createJiggleRetryState();
|
|
96
|
+
scheduleNext();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stop the chain (component closed, etc.). */
|
|
101
|
+
function stop() {
|
|
102
|
+
clearTimer();
|
|
103
|
+
state = stopRetry(state);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
start,
|
|
108
|
+
feed,
|
|
109
|
+
stop,
|
|
110
|
+
getState: () => ({ ...state, tuiFrameSeen }),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Pure logic for attach jiggle-retry: detect full-clear, schedule backoff retries. */
|
|
2
2
|
|
|
3
|
-
export const BACKOFF_MS = Object.freeze([120, 500, 1500, 3000]);
|
|
3
|
+
export const BACKOFF_MS = Object.freeze([120, 500, 1500, 3000, 6000, 10000, 15000, 20000]);
|
|
4
4
|
export const MAX_RETRIES = BACKOFF_MS.length;
|
|
5
5
|
|
|
6
6
|
// Verified empirically in issue #2: a resize jiggle makes the child pi-tui run
|
|
@@ -10,8 +10,12 @@ export const MAX_RETRIES = BACKOFF_MS.length;
|
|
|
10
10
|
// signal. If pi-tui ever changes fullRender to skip the clear, the detector
|
|
11
11
|
// silently degrades to the old one-shot behavior after MAX_RETRIES.
|
|
12
12
|
const FULL_CLEAR = "\x1b[2J";
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// pi-tui begins every frame (including differential frames) with a synchronized
|
|
14
|
+
// output start sequence; boot-time extension output never contains it, so the
|
|
15
|
+
// first occurrence marks "child TUI has started rendering" (issue #10).
|
|
16
|
+
const TUI_FRAME_START = "\x1b[?2026h";
|
|
17
|
+
/** Carry enough bytes to catch either target sequence split at a chunk boundary. */
|
|
18
|
+
const CARRY_LEN = TUI_FRAME_START.length - 1; // 7 bytes
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* @typedef {Object} JiggleRetryState
|
|
@@ -27,18 +31,22 @@ export function createJiggleRetryState() {
|
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Feed PTY output data into the state machine.
|
|
30
|
-
* Returns new state, updated carry buffer, and
|
|
34
|
+
* Returns new state, updated carry buffer, and which target sequences were found.
|
|
31
35
|
* @param {JiggleRetryState} state
|
|
32
36
|
* @param {string} data - New output data chunk.
|
|
33
37
|
* @param {string} carry - Leftover partial escape sequence from previous chunk.
|
|
34
|
-
* @returns {{ state: JiggleRetryState, carry: string, clearFound: boolean }}
|
|
38
|
+
* @returns {{ state: JiggleRetryState, carry: string, clearFound: boolean, frameStartFound: boolean }}
|
|
35
39
|
*/
|
|
36
40
|
export function feedOutput(state, data, carry) {
|
|
37
41
|
const combined = carry + data;
|
|
38
42
|
const clearFound = hasFullClearSequence(combined);
|
|
39
|
-
const
|
|
43
|
+
const frameStartFound = hasTuiFrameStart(combined);
|
|
44
|
+
// Always keep the tail carry: a clear hit stops the chain (carry irrelevant), and
|
|
45
|
+
// a frame-start hit must still preserve a trailing half-sequence so an immediately
|
|
46
|
+
// following clear split across the next chunk is not missed.
|
|
47
|
+
const newCarry = tailCarry(combined);
|
|
40
48
|
const newState = clearFound ? { ...state, clearDetected: true } : state;
|
|
41
|
-
return { state: newState, carry: newCarry, clearFound };
|
|
49
|
+
return { state: newState, carry: newCarry, clearFound, frameStartFound };
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/**
|
|
@@ -79,12 +87,23 @@ export function hasFullClearSequence(data) {
|
|
|
79
87
|
return data.includes(FULL_CLEAR);
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Check if data contains the TUI frame-start (synchronized output) sequence.
|
|
92
|
+
* pi-tui begins every frame with \x1b[?2026h; boot-time extension output never
|
|
93
|
+
* contains it, so the first occurrence marks "child TUI has started rendering".
|
|
94
|
+
* @param {string} data
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function hasTuiFrameStart(data) {
|
|
98
|
+
return data.includes(TUI_FRAME_START);
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
/** Extract the tail carry for cross-chunk boundary detection. */
|
|
83
102
|
function tailCarry(data) {
|
|
84
|
-
// We only need to carry up to CARRY_LEN bytes to catch a split \x1b[2J
|
|
103
|
+
// We only need to carry up to CARRY_LEN bytes to catch a split \x1b[2J or
|
|
104
|
+
// \x1b[?2026h. Find the last \x1b in the tail — everything before it can't be
|
|
105
|
+
// part of a split escape sequence.
|
|
85
106
|
const tail = data.slice(-CARRY_LEN);
|
|
86
|
-
// Find the last \x1b in the tail — everything before it can't be part of
|
|
87
|
-
// a split escape sequence.
|
|
88
107
|
const escIdx = tail.lastIndexOf("\x1b");
|
|
89
108
|
return escIdx >= 0 ? tail.slice(escIdx) : "";
|
|
90
109
|
}
|
|
@@ -22,6 +22,27 @@ export function shouldScheduleAttachRenderForMessage(type) {
|
|
|
22
22
|
|
|
23
23
|
export const ATTACH_OUTPUT_RENDER_INTERVAL_MS = 40;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the PTY cursor to an ABSOLUTE buffer row for the projected window
|
|
27
|
+
* [start, start + height). xterm's buffer cursorY is relative to baseY (the child
|
|
28
|
+
* terminal's viewport top when scrolled to bottom), so the absolute row is
|
|
29
|
+
* baseY + cursorY. Returns null when the cursor is outside the projected window
|
|
30
|
+
* (e.g. the user scrolled up into history).
|
|
31
|
+
*
|
|
32
|
+
* The returned row must stay absolute: projection loops pass absolute buffer
|
|
33
|
+
* indices (buf.getLine(i) for i in [start, end)) and mouse selection points are
|
|
34
|
+
* absolute too. Returning a start-relative row only matches while start === 0;
|
|
35
|
+
* once scrollback exists the cursor cell never matches, which silently drops the
|
|
36
|
+
* visible PTY-cursor block AND the CURSOR_MARKER used to position the hardware
|
|
37
|
+
* cursor for IME candidate windows.
|
|
38
|
+
*/
|
|
39
|
+
export function projectPtyCursor(buf, start, height) {
|
|
40
|
+
if (typeof buf.cursorX !== "number" || typeof buf.cursorY !== "number" || buf.cursorX < 0 || buf.cursorY < 0) return null;
|
|
41
|
+
const row = buf.baseY + buf.cursorY;
|
|
42
|
+
if (row < start || row >= start + height) return null;
|
|
43
|
+
return { row, col: buf.cursorX };
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
/**
|
|
26
47
|
* Coalesce PTY parser callbacks into a bounded stream of repaint requests.
|
|
27
48
|
*
|