@zhuxixi/pi-agent-board 0.3.0 → 0.3.1

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.
@@ -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;
@@ -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;
@@ -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
  *
package/src/core/rows.mjs CHANGED
@@ -36,6 +36,7 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
36
36
  * @property {string|null} followUpPreview
37
37
  * @property {string} steeringState
38
38
  * @property {number} lastActivityAt
39
+ * @property {number} createdAt
39
40
  */
40
41
 
41
42
  /** @param {Row} row @returns {SemanticState} */
@@ -139,6 +140,7 @@ export function rowView(row, now) {
139
140
  followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
140
141
  steeringState: row.state?.steering?.status ?? "none",
141
142
  lastActivityAt,
143
+ createdAt: row.meta.createdAt ?? 0,
142
144
  };
143
145
  }
144
146
 
@@ -148,8 +150,9 @@ function oneLine(text) {
148
150
  }
149
151
 
150
152
  /**
151
- * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then most
152
- * recently active first. Empty groups are omitted.
153
+ * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then
154
+ * creation order (oldest first) — the order is stable and never reshuffles on activity.
155
+ * Empty groups are omitted.
153
156
  * @param {Row[]} rows
154
157
  * @param {number} now
155
158
  * @returns {Array<{ state: SemanticState, label: string, rows: RowView[] }>}
@@ -171,7 +174,7 @@ export function groupRows(rows, now) {
171
174
  * a single folder renders as a flat list (no redundant folder header).
172
175
  * @param {Row[]} rows
173
176
  * @param {number} now
174
- * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], lastActivityAt: number, pinned: boolean }> }>}
177
+ * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], createdAt: number, pinned: boolean }> }>}
175
178
  */
176
179
  export function groupRowsByFolder(rows, now) {
177
180
  const views = rows.map((r) => rowView(r, now));
@@ -184,23 +187,35 @@ export function groupRowsByFolder(rows, now) {
184
187
  const key = view.folderKey;
185
188
  const existing = byFolder.get(key);
186
189
  if (existing) existing.rows.push(view);
187
- else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], lastActivityAt: view.lastActivityAt, pinned: view.pinned });
190
+ else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], createdAt: view.createdAt, pinned: view.pinned });
188
191
  }
189
192
  const folders = [...byFolder.values()].map((folder) => {
190
193
  folder.rows = sortRowViews(folder.rows);
191
- folder.lastActivityAt = Math.max(...folder.rows.map((r) => r.lastActivityAt));
194
+ // Folder creation time = its first-created row, so folders keep a stable,
195
+ // creation-ordered position and don't jump when any row inside gets busy.
196
+ folder.createdAt = Math.min(...folder.rows.map((r) => r.createdAt));
192
197
  folder.pinned = folder.rows.some((r) => r.pinned);
193
198
  return folder;
194
- }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt || a.name.localeCompare(b.name));
199
+ }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.createdAt - b.createdAt || a.name.localeCompare(b.name));
195
200
  const showFolders = folders.length > 1;
196
201
  groups.push({ state, label: GROUP_LABELS[state], rowCount: inState.length, showFolders, folders });
197
202
  }
198
203
  return groups;
199
204
  }
200
205
 
201
- /** @param {RowView[]} views */
206
+ /**
207
+ * Stable row order: pinned first, then creation order (oldest first), then id.
208
+ * Activity time deliberately does not participate, so rows never reshuffle while
209
+ * sessions are running.
210
+ * @param {RowView[]} views
211
+ */
202
212
  function sortRowViews(views) {
203
- return views.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt);
213
+ return views.sort(
214
+ (a, b) =>
215
+ Number(b.pinned) - Number(a.pinned) ||
216
+ a.createdAt - b.createdAt ||
217
+ a.id.localeCompare(b.id),
218
+ );
204
219
  }
205
220
 
206
221
  /** @param {string} path */
@@ -62,12 +62,12 @@ export const GROUP_ORDER = /** @type {const} */ ([
62
62
 
63
63
  /** Human labels for group headers. @type {Record<SemanticState,string>} */
64
64
  export const GROUP_LABELS = {
65
- needs_input: "Needs input",
65
+ needs_input: "Needs answer",
66
66
  working: "Running",
67
67
  queued: "Queued",
68
68
  failed: "Failed",
69
69
  completed: "Done",
70
- idle: "In Progress",
70
+ idle: "Needs instructions",
71
71
  stopped: "Stopped",
72
72
  };
73
73
 
@@ -36,6 +36,7 @@ import {
36
36
  writeLaunchPrefs,
37
37
  writeMeta,
38
38
  writeState,
39
+ writeStatus,
39
40
  } from "../core/store.mjs";
40
41
  import { diagnoseNodePtyFailure, ensureNodePtySpawnHelperExecutable, nodePtyFallbackMessage, probeNodePtyEnvironment } from "../core/pty-support.mjs";
41
42
  import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
@@ -270,6 +271,15 @@ export function createService(opts) {
270
271
  state.summary = completionSummary(state);
271
272
  state.lastActivityAt = Date.now();
272
273
  state.updatedAt = Date.now();
274
+ // Also clear autoState in the run status so in-flight model passes
275
+ // (job-runner / state-runner) see the manual completion and skip refinement.
276
+ if (state.currentRunId) {
277
+ const status = readStatus(root, viewId, state.currentRunId);
278
+ if (status) {
279
+ status.autoState = null;
280
+ writeStatus(root, status);
281
+ }
282
+ }
273
283
  writeState(root, state);
274
284
  return { ok: true };
275
285
  }
@@ -958,7 +968,7 @@ export function createService(opts) {
958
968
  s.question = null;
959
969
  s.pendingQuestions = [];
960
970
  s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
961
- s.summary = failed ? "Failed (PTY host exited)" : "In Progress";
971
+ s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
962
972
  s.updatedAt = now;
963
973
  writeState(root, s);
964
974
  appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
@@ -923,7 +923,7 @@ export class DashboardComponent implements Component {
923
923
  currentRunId: null,
924
924
  semanticState: "idle",
925
925
  processState: "exited",
926
- summary: "In Progress",
926
+ summary: "Needs instructions",
927
927
  lastActivityAt: Date.now(),
928
928
  updatedAt: Date.now(),
929
929
  needsInput: false,
@@ -1754,7 +1754,7 @@ function headerTextRows(
1754
1754
  function headerStageSummary(theme: ThemeLike, counts: HeaderCounts, filterQuery: string, compact: boolean): string {
1755
1755
  const joiner = theme.fg("dim", " · ");
1756
1756
  const parts = [
1757
- headerStagePart(theme, "needs_input", counts.needs, compact ? "awaiting" : "awaiting input"),
1757
+ headerStagePart(theme, "needs_input", counts.needs, compact ? "answer" : "needs answer"),
1758
1758
  headerStagePart(theme, "working", counts.working, "running"),
1759
1759
  headerUnreadPart(theme, counts.unread),
1760
1760
  headerStagePart(theme, "completed", counts.completed, "done"),