@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.
- 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-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-rename-state-labels-design.md +80 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +28 -4
- package/src/core/auto-state.mjs +62 -7
- package/src/core/derive.mjs +4 -4
- package/src/core/events.mjs +3 -2
- 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 +11 -1
- package/src/ui/dashboard.ts +2 -2
- package/src/ui/pty-attach.ts +26 -77
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;
|
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;
|
|
@@ -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
|
*
|
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
|
|
152
|
-
*
|
|
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[],
|
|
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],
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
/**
|
|
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(
|
|
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 */
|
package/src/core/types.mjs
CHANGED
|
@@ -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
|
|
65
|
+
needs_input: "Needs answer",
|
|
66
66
|
working: "Running",
|
|
67
67
|
queued: "Queued",
|
|
68
68
|
failed: "Failed",
|
|
69
69
|
completed: "Done",
|
|
70
|
-
idle: "
|
|
70
|
+
idle: "Needs instructions",
|
|
71
71
|
stopped: "Stopped",
|
|
72
72
|
};
|
|
73
73
|
|
package/src/runtime/service.mjs
CHANGED
|
@@ -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)" : "
|
|
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 } });
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -923,7 +923,7 @@ export class DashboardComponent implements Component {
|
|
|
923
923
|
currentRunId: null,
|
|
924
924
|
semanticState: "idle",
|
|
925
925
|
processState: "exited",
|
|
926
|
-
summary: "
|
|
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 ? "
|
|
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"),
|