@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -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
- persist(true);
222
+ persistUnlessManual(true);
210
223
  }
211
224
  return maybeModelSummary(config, status);
212
225
  })
213
226
  .then((changed) => {
214
- if (changed) persist(true);
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,
@@ -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
- 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- done: the requested work is complete, final answer given, no user input required.\n\nReturn ONLY minified JSON with this shape:\n{"state":"needs_input|in_progress|done","confidence":"high|medium|low","reason":"short reason <=18 words","question":"user-facing question or null"}\n\nLast assistant response:\n${text}`;
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
- const kind = normalizeKind(obj.state ?? obj.kind ?? obj.status);
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: cleanReason(obj.reason) || defaultReason(kind),
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
- if (done && !pending) {
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
+ }
@@ -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 input";
53
+ return "Needs answer";
54
54
  case "idle":
55
- return "In Progress";
55
+ return "Needs instructions";
56
56
  case "completed":
57
57
  return "Done";
58
58
  case "failed":
@@ -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
- const nb = detectNeedsInput(text);
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
+ }
@@ -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
- /** Carry enough bytes to catch a split escape sequence at chunk boundary. */
14
- const CARRY_LEN = FULL_CLEAR.length - 1; // 3 bytes: \x1b, \x1b[, \x1b[2
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 whether a clear was found.
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 newCarry = clearFound ? "" : tailCarry(combined);
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
  *