pi-better-background-tasks 0.4.0 → 0.5.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 +6 -4
- package/package.json +1 -1
- package/src/failures.ts +9 -1
- package/src/navigator-provider.ts +9 -6
- package/src/output.ts +5 -1
- package/src/runtime.ts +25 -9
- package/src/shared-failure-observations.ts +48 -12
- package/src/shared-log-utils.ts +6 -12
- package/src/shared-navigator.ts +169 -125
- package/src/shared-sandbox-core.ts +78 -14
- package/src/tools.ts +1 -1
package/README.md
CHANGED
|
@@ -99,9 +99,11 @@ After `/new`, `/resume`, fork, or switching to another session, the previous
|
|
|
99
99
|
session's tasks keep running but are paused from Pi's side: watches do not poll,
|
|
100
100
|
remote tmux output is not collected, and `timeout_seconds` deadlines are not
|
|
101
101
|
enforced until that session is active again. An overdue deadline is enforced as
|
|
102
|
-
soon as the session resumes, so a timeout can land late but is never skipped.
|
|
103
|
-
local process that exits in the meantime
|
|
104
|
-
callback is delivered when its session resumes.
|
|
102
|
+
soon as the session resumes, so a timeout can land late but is never skipped.
|
|
103
|
+
While the same Pi process stays open, a local process that exits in the meantime
|
|
104
|
+
is recorded as finished, and its callback is delivered when its session resumes.
|
|
105
|
+
If you quit Pi first, nothing records that exit: when the session is resumed in a
|
|
106
|
+
new Pi process, a task whose process is gone is marked lost.
|
|
105
107
|
|
|
106
108
|
## Watch conditions
|
|
107
109
|
|
|
@@ -151,7 +153,7 @@ evidence is reported as **observation incomplete**.
|
|
|
151
153
|
Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
|
|
152
154
|
or `bg_task` to declare intent before launch; a malformed declaration starts
|
|
153
155
|
nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
|
|
154
|
-
`[1]` for a no-match probe) is recorded as an **Expected failure** that needs no
|
|
156
|
+
`[1]` for a no-match probe; a `0` is ignored) is recorded as an **Expected failure** that needs no
|
|
155
157
|
action; signals and timeouts never are. When a task with an `operation_id`
|
|
156
158
|
succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
|
|
157
159
|
target, and owner (the same session id, or for sessionless tasks the same Pi
|
package/package.json
CHANGED
package/src/failures.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import {
|
|
4
|
-
activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
|
|
4
|
+
actionableFailures, activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
|
|
5
5
|
observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readCommandIntent, readFailureState,
|
|
6
6
|
type FailureEvent, type FailureState,
|
|
7
7
|
} from "./shared-failure-observations.js";
|
|
@@ -12,6 +12,14 @@ import type { BackgroundTaskMeta } from "./types.js";
|
|
|
12
12
|
|
|
13
13
|
export const failurePath = (id: string): string => join(taskDir(id), "failures.jsonl");
|
|
14
14
|
export const failureSummary = (id: string): string => formatFailureSummary(readFailureState(failurePath(id)));
|
|
15
|
+
/**
|
|
16
|
+
* The failure summary plus whether anything needs action. A navigator row leads with failure text
|
|
17
|
+
* only when something does; the quiet history line never displaces the command (#332).
|
|
18
|
+
*/
|
|
19
|
+
export function failureView(id: string): { text: string; actionable: boolean } {
|
|
20
|
+
const state = readFailureState(failurePath(id));
|
|
21
|
+
return { text: formatFailureSummary(state), actionable: actionableFailures(state).length > 0 };
|
|
22
|
+
}
|
|
15
23
|
|
|
16
24
|
export function recordFailure(meta: BackgroundTaskMeta, operation: string, summary: string, eventKey: unknown,
|
|
17
25
|
options: { category?: string; expected?: boolean; incomplete?: boolean; evidence?: string; at?: number } = {}): void {
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
} from "./shared-navigator.ts";
|
|
11
11
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
-
import {
|
|
14
|
-
import { failurePath,
|
|
13
|
+
import { actionableFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
|
|
14
|
+
import { failurePath, failureView } from "./failures.js";
|
|
15
15
|
import { readLog } from "./logs.js";
|
|
16
16
|
import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
17
17
|
import { stopTask } from "./runtime.js";
|
|
@@ -105,7 +105,9 @@ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): b
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
|
|
108
|
-
|
|
108
|
+
// Only a failure that needs action replaces the row's command; history stays in the detail view (#332).
|
|
109
|
+
const view = failureView(meta.id);
|
|
110
|
+
const failure = view.actionable ? view.text : "";
|
|
109
111
|
const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
|
|
110
112
|
return {
|
|
111
113
|
providerId: "background-tasks",
|
|
@@ -129,7 +131,8 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
|
|
|
129
131
|
|
|
130
132
|
function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
131
133
|
if (!meta) return null;
|
|
132
|
-
const
|
|
134
|
+
const view = failureView(meta.id);
|
|
135
|
+
const failure = view.text;
|
|
133
136
|
const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
|
|
134
137
|
const command = commandLabel(meta);
|
|
135
138
|
const metadata = [
|
|
@@ -164,7 +167,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
|
|
|
164
167
|
title: meta.name || meta.id,
|
|
165
168
|
status: meta.status,
|
|
166
169
|
statusTone: toneForStatus(meta.status),
|
|
167
|
-
subtitle:
|
|
170
|
+
subtitle: view.actionable ? failure.split("\n")[0]! : compactCommandLabel(meta),
|
|
168
171
|
metadata,
|
|
169
172
|
foldedSections: [{
|
|
170
173
|
id: "command",
|
|
@@ -217,7 +220,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
|
|
|
217
220
|
|
|
218
221
|
function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
|
|
219
222
|
const facts: string[] = [];
|
|
220
|
-
const incident =
|
|
223
|
+
const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
|
|
221
224
|
if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
|
|
222
225
|
if (meta.status === "running") {
|
|
223
226
|
const stall = observeBackgroundTaskStall(meta, now);
|
package/src/output.ts
CHANGED
|
@@ -548,13 +548,16 @@ export function formatStatus(
|
|
|
548
548
|
if (pageKind === "f") return formatLog(meta.id, { ...options, raw: true });
|
|
549
549
|
if (pageKind === "t") return formatVerbose(meta, options);
|
|
550
550
|
if (options.verbose) return formatVerbose(meta, options);
|
|
551
|
+
// A bg_task_list page cursor pages the list, not this task: say so rather than report it as a
|
|
552
|
+
// stale status cursor (#332).
|
|
553
|
+
const listCursor = pageKind === "l";
|
|
551
554
|
const state = failureStateFor(meta.id);
|
|
552
555
|
const resource = `status:${scopeKey(options)}:${meta.id}`;
|
|
553
556
|
const revision = inspectStatusRevision({
|
|
554
557
|
resource,
|
|
555
558
|
contentRevision: contentRevision(meta),
|
|
556
559
|
failureRevision: failureRevision(state),
|
|
557
|
-
cursor: options.cursor,
|
|
560
|
+
cursor: listCursor ? undefined : options.cursor,
|
|
558
561
|
});
|
|
559
562
|
if (options.cursor && revision.change === "none") {
|
|
560
563
|
return assembleBackgroundContent({
|
|
@@ -574,6 +577,7 @@ export function formatStatus(
|
|
|
574
577
|
// Change/reset and read-gap facts are short and decision-relevant: they are
|
|
575
578
|
// budgeted with the decision section, ahead of long incident rows.
|
|
576
579
|
const changeFacts = [
|
|
580
|
+
...(listCursor ? ["cursor ignored: it is a bg_task_list page cursor; pass it to bg_task_list, or pass this status's statusCursor here."] : []),
|
|
577
581
|
...(revision.change === "failure" ? ["change=failure"] : []),
|
|
578
582
|
...(revision.reset ? [`reset=${revision.reset}`] : []),
|
|
579
583
|
...(log.error ? [`log unreadable: ${oneLine(log.error, 200)}; cannot treat this as an empty healthy log.`] : []),
|
package/src/runtime.ts
CHANGED
|
@@ -34,8 +34,11 @@ const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
|
|
|
34
34
|
const activePolls = new Set<string>();
|
|
35
35
|
const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
36
36
|
const LOG_RETENTION_CHECK_MS = 1000;
|
|
37
|
-
const handoffTimers = new Map<string, ReturnType<typeof
|
|
37
|
+
const handoffTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
38
|
+
// The handoff check backs off from 250 ms to 4 s while the task keeps running,
|
|
39
|
+
// and returns to 250 ms once its process is gone so the grace period is timed closely.
|
|
38
40
|
const HANDOFF_CHECK_MS = 250;
|
|
41
|
+
const HANDOFF_MAX_CHECK_MS = 4_000;
|
|
39
42
|
const HANDOFF_LOST_GRACE_MS = 5_000;
|
|
40
43
|
let scheduledWorkSuspended = false;
|
|
41
44
|
const REMOTE_SESSION_POLL_MS = 100;
|
|
@@ -62,8 +65,9 @@ export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
|
62
65
|
* tasks keep running, but their watches do not poll, remote tmux output is not
|
|
63
66
|
* collected, and their `timeout_seconds` deadlines are not enforced until that
|
|
64
67
|
* session is active again; an overdue deadline is enforced immediately on resume.
|
|
65
|
-
* A local process that exits meanwhile
|
|
66
|
-
* is delivered when its session resumes
|
|
68
|
+
* A local process that exits meanwhile, while this Pi process is still running,
|
|
69
|
+
* is recorded as terminal and its callback is delivered when its session resumes;
|
|
70
|
+
* after Pi quits, a resumed task whose process is gone is marked lost instead.
|
|
67
71
|
*/
|
|
68
72
|
export function suspendScheduledWork(): void {
|
|
69
73
|
scheduledWorkSuspended = true;
|
|
@@ -71,7 +75,7 @@ export function suspendScheduledWork(): void {
|
|
|
71
75
|
for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
|
|
72
76
|
for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
|
|
73
77
|
for (const timer of logRetentionTimers.values()) clearInterval(timer);
|
|
74
|
-
for (const timer of handoffTimers.values())
|
|
78
|
+
for (const timer of handoffTimers.values()) clearTimeout(timer);
|
|
75
79
|
handoffTimers.clear();
|
|
76
80
|
watcherTimers.clear();
|
|
77
81
|
remoteSessionTimers.clear();
|
|
@@ -528,7 +532,9 @@ function scheduleHandoff(
|
|
|
528
532
|
stopHandoff(id);
|
|
529
533
|
if (scheduledWorkSuspended) return;
|
|
530
534
|
let deadSince: number | undefined;
|
|
531
|
-
|
|
535
|
+
let delayMs = HANDOFF_CHECK_MS;
|
|
536
|
+
const check = () => {
|
|
537
|
+
handoffTimers.delete(id);
|
|
532
538
|
const meta = readMeta(id);
|
|
533
539
|
if (!meta) {
|
|
534
540
|
stopHandoff(id);
|
|
@@ -554,16 +560,26 @@ function scheduleHandoff(
|
|
|
554
560
|
clearProcessTimeout(id);
|
|
555
561
|
stopLogRetention(id);
|
|
556
562
|
markProcessLost(pi, meta, getActiveSession);
|
|
563
|
+
return;
|
|
557
564
|
}
|
|
565
|
+
delayMs = HANDOFF_CHECK_MS;
|
|
566
|
+
} else {
|
|
567
|
+
delayMs = Math.min(HANDOFF_MAX_CHECK_MS, delayMs * 2);
|
|
558
568
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
569
|
+
arm();
|
|
570
|
+
};
|
|
571
|
+
const arm = () => {
|
|
572
|
+
if (scheduledWorkSuspended) return;
|
|
573
|
+
const timer = setTimeout(check, delayMs);
|
|
574
|
+
timer.unref();
|
|
575
|
+
handoffTimers.set(id, timer);
|
|
576
|
+
};
|
|
577
|
+
arm();
|
|
562
578
|
}
|
|
563
579
|
|
|
564
580
|
function stopHandoff(id: string): void {
|
|
565
581
|
const timer = handoffTimers.get(id);
|
|
566
|
-
if (timer)
|
|
582
|
+
if (timer) clearTimeout(timer);
|
|
567
583
|
handoffTimers.delete(id);
|
|
568
584
|
}
|
|
569
585
|
|
|
@@ -104,24 +104,60 @@ export interface CommandIntent {
|
|
|
104
104
|
export type CommandIntentField = keyof CommandIntent;
|
|
105
105
|
const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
107
|
+
* Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
|
|
108
|
+
* bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
|
|
109
|
+
* process log and session record keep the raw arguments; mirroring it lets the parent's replay read
|
|
110
|
+
* the declaration the child executed with (#332).
|
|
111
|
+
* - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
|
|
112
|
+
* - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
|
|
113
|
+
* a scalar `0`, `""`, or `false` falls through to null.
|
|
114
|
+
*/
|
|
115
|
+
function coerceInteger(value: unknown): unknown {
|
|
116
|
+
if (value === null) return 0;
|
|
117
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
|
|
118
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
|
|
122
|
+
if (value == null) return undefined;
|
|
123
|
+
if (key === "expectedExitCodes") {
|
|
124
|
+
if (value === 0 || value === "" || value === false) return undefined;
|
|
125
|
+
if (!Array.isArray(value)) return value;
|
|
126
|
+
// 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
|
|
127
|
+
const codes = value.map(coerceInteger).filter((code) => code !== 0);
|
|
128
|
+
if (codes.length === 0 && value.length > 0) return undefined;
|
|
129
|
+
return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
|
|
130
|
+
}
|
|
131
|
+
if (value === "") return undefined;
|
|
132
|
+
return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
|
|
136
|
+
* declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
|
|
137
|
+
* argument validation drops or nulls them before a tool runs, while the process log and session
|
|
138
|
+
* record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
|
|
139
|
+
* from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
|
|
140
|
+
* exact operation identity go through this, so all see the same declaration from the same input.
|
|
141
|
+
* Malformed values are kept as they are for the validator to refuse.
|
|
112
142
|
*/
|
|
113
143
|
export function withoutAbsentIntent<T>(args: T): T {
|
|
114
144
|
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
115
145
|
const input = args as Record<string, unknown>;
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
let out: Record<string, unknown> | undefined;
|
|
147
|
+
for (const key of INTENT_FIELDS) {
|
|
148
|
+
if (!(key in input)) continue;
|
|
149
|
+
const value = normalizeIntentField(key, input[key]);
|
|
150
|
+
if (value === input[key]) continue;
|
|
151
|
+
out ??= { ...input };
|
|
152
|
+
if (value === undefined) delete out[key];
|
|
153
|
+
else out[key] = value;
|
|
154
|
+
}
|
|
155
|
+
return (out ?? args) as T;
|
|
120
156
|
}
|
|
121
157
|
/**
|
|
122
|
-
* Validate structured intent fields
|
|
158
|
+
* Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
|
|
123
159
|
* ones are an error, and the caller must not run the command. `names` renames fields in the error
|
|
124
|
-
* (e.g. snake_case parameters).
|
|
160
|
+
* (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
|
|
125
161
|
*/
|
|
126
162
|
export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
|
|
127
163
|
const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
|
|
@@ -138,7 +174,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
|
|
|
138
174
|
if (codes !== undefined) {
|
|
139
175
|
if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
|
|
140
176
|
!codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
|
|
141
|
-
return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
|
|
177
|
+
return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
|
|
142
178
|
}
|
|
143
179
|
intent.expectedExitCodes = [...codes] as number[];
|
|
144
180
|
}
|
package/src/shared-log-utils.ts
CHANGED
|
@@ -819,29 +819,22 @@ export interface OutputControls {
|
|
|
819
819
|
maxBytes?: unknown;
|
|
820
820
|
/** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
|
|
821
821
|
lines?: unknown;
|
|
822
|
-
/** Deprecated alias names the caller used (whether or not the canonical name won). */
|
|
823
|
-
deprecated: string[];
|
|
824
822
|
}
|
|
825
823
|
|
|
826
824
|
/**
|
|
827
825
|
* Resolve output-control parameters. Both spellings work; when both are given
|
|
828
|
-
* the canonical name wins.
|
|
829
|
-
*
|
|
826
|
+
* the canonical name wins. An explicit `null` is "not given", so a null
|
|
827
|
+
* canonical value never hides an alias value (#332). Values are returned
|
|
828
|
+
* unvalidated so each surface keeps its own defaults and hard caps.
|
|
830
829
|
*/
|
|
831
830
|
export function readOutputControls(params: unknown): OutputControls {
|
|
832
831
|
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
|
833
|
-
const
|
|
834
|
-
const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
|
|
835
|
-
const alias = OUTPUT_CONTROL_ALIASES[canonical];
|
|
836
|
-
if (p[alias] !== undefined) deprecated.push(alias);
|
|
837
|
-
return p[canonical] !== undefined ? p[canonical] : p[alias];
|
|
838
|
-
};
|
|
832
|
+
const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
|
|
839
833
|
const maxBytes = pick("max_bytes");
|
|
840
834
|
const lines = pick("lines");
|
|
841
835
|
return {
|
|
842
836
|
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
843
837
|
...(lines !== undefined ? { lines } : {}),
|
|
844
|
-
deprecated,
|
|
845
838
|
};
|
|
846
839
|
}
|
|
847
840
|
|
|
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
|
|
|
851
844
|
|
|
852
845
|
/** Parse an `include` request into known values and unknown ones (both deduplicated). */
|
|
853
846
|
export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
|
|
854
|
-
|
|
847
|
+
// Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
|
|
848
|
+
const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
|
|
855
849
|
const include = new Set<OutputInclude>();
|
|
856
850
|
const unknown: string[] = [];
|
|
857
851
|
for (const entry of raw) {
|
package/src/shared-navigator.ts
CHANGED
|
@@ -93,8 +93,12 @@ type NavigatorState = {
|
|
|
93
93
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
94
94
|
mainListDeadlineScheduler?: RenderScheduler;
|
|
95
95
|
editorComponent?: Component;
|
|
96
|
+
/** The editor Pi currently has mounted (the wrapper the factory returned last). */
|
|
97
|
+
editorFocusTarget?: unknown;
|
|
96
98
|
detailOverlayRows?: number;
|
|
97
99
|
dispose?: () => void;
|
|
100
|
+
/** The mounted detail overlay, reused instead of stacking a second one. */
|
|
101
|
+
overlay?: { showDetail(navigatorId: string): void; focus(): void };
|
|
98
102
|
};
|
|
99
103
|
|
|
100
104
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
@@ -199,6 +203,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
199
203
|
const s = state();
|
|
200
204
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
201
205
|
s.dispose = undefined;
|
|
206
|
+
s.overlay = undefined;
|
|
202
207
|
stopMainListWidget();
|
|
203
208
|
const activeCtx = ctx ?? s.uiCtx;
|
|
204
209
|
s.uiCtx = undefined;
|
|
@@ -627,7 +632,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
627
632
|
|
|
628
633
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
629
634
|
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
630
|
-
|
|
635
|
+
const proxy = new Proxy(inner, {
|
|
631
636
|
get(target, prop) {
|
|
632
637
|
if (prop === "handleInput") {
|
|
633
638
|
return (data: string) => {
|
|
@@ -644,12 +649,24 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
644
649
|
return Reflect.set(target, prop, value);
|
|
645
650
|
},
|
|
646
651
|
});
|
|
652
|
+
// Pi mounts and focuses whatever the factory returns; an extension that
|
|
653
|
+
// re-installs the editor replaces it, so this always names the live one.
|
|
654
|
+
state().editorFocusTarget = proxy;
|
|
655
|
+
return proxy;
|
|
647
656
|
}
|
|
648
657
|
|
|
649
658
|
function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
650
659
|
const s = state();
|
|
651
660
|
const rows = listRows();
|
|
652
|
-
if (rows.length === 0)
|
|
661
|
+
if (rows.length === 0) {
|
|
662
|
+
// Every row is gone, but an overlay that lost focus may still be mounted: Esc still closes it.
|
|
663
|
+
if (s.overlay && deps.matchKey(data, "escape")) {
|
|
664
|
+
unfocusMainList();
|
|
665
|
+
dismissOverlay();
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
653
670
|
if (!s.mainListFocused) {
|
|
654
671
|
if (!deps.isOpenTrigger(data)) return false;
|
|
655
672
|
focusMainList();
|
|
@@ -673,7 +690,10 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
673
690
|
}
|
|
674
691
|
if (deps.matchKey(data, "enter")) {
|
|
675
692
|
const selected = selectedMainListRow();
|
|
676
|
-
if (selected?.parentRow)
|
|
693
|
+
if (selected?.parentRow) {
|
|
694
|
+
unfocusMainList();
|
|
695
|
+
dismissOverlay();
|
|
696
|
+
}
|
|
677
697
|
else openNavigator();
|
|
678
698
|
return true;
|
|
679
699
|
}
|
|
@@ -683,11 +703,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
683
703
|
}
|
|
684
704
|
if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
|
|
685
705
|
unfocusMainList();
|
|
706
|
+
dismissOverlay();
|
|
686
707
|
return true;
|
|
687
708
|
}
|
|
688
709
|
return false;
|
|
689
710
|
}
|
|
690
711
|
|
|
712
|
+
/** Close a detail overlay that is still mounted while the editor has focus. */
|
|
713
|
+
function dismissOverlay(): void {
|
|
714
|
+
const s = state();
|
|
715
|
+
if (!s.overlay) return;
|
|
716
|
+
try { s.dispose?.(); } catch { /* ignore */ }
|
|
717
|
+
}
|
|
718
|
+
|
|
691
719
|
function handleMainListCloseKey(): void {
|
|
692
720
|
const row = selectedMainListRow();
|
|
693
721
|
if (!row || row.parentRow) return;
|
|
@@ -721,20 +749,54 @@ function openNavigator(): void {
|
|
|
721
749
|
const rows = listRows();
|
|
722
750
|
if (rows.length === 0) return;
|
|
723
751
|
const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
|
|
752
|
+
// An overlay that lost focus (another UI took it and handed it to the editor)
|
|
753
|
+
// is still mounted: switch its detail and focus it rather than stacking a
|
|
754
|
+
// second overlay over it, whose Esc would only reveal the stale one below.
|
|
755
|
+
const mounted = s.overlay;
|
|
756
|
+
if (mounted) {
|
|
757
|
+
// showDetail may close the overlay (and clear s.overlay); focus() is then a no-op.
|
|
758
|
+
mounted.showDetail(selectedId);
|
|
759
|
+
mounted.focus();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
724
762
|
try {
|
|
725
763
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
726
764
|
s.dispose = undefined;
|
|
727
765
|
let disposeToken: (() => void) | undefined;
|
|
766
|
+
let overlayRef: NavigatorState["overlay"];
|
|
767
|
+
let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
|
|
768
|
+
const release = () => {
|
|
769
|
+
if (overlayRef && s.overlay === overlayRef) s.overlay = undefined;
|
|
770
|
+
};
|
|
728
771
|
const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
|
|
729
|
-
const component = createOverlayComponent(rows, deps, tui, theme,
|
|
772
|
+
const component = createOverlayComponent(rows, deps, tui, theme, (value) => {
|
|
773
|
+
release();
|
|
774
|
+
// Pi restores focus to the editor that was focused when the overlay opened. If an
|
|
775
|
+
// extension re-installed the editor since, that instance is unmounted and every key
|
|
776
|
+
// would be lost; hand focus to the live editor before closing.
|
|
777
|
+
const liveEditor = s.editorFocusTarget;
|
|
778
|
+
if (liveEditor) {
|
|
779
|
+
try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
|
|
780
|
+
}
|
|
781
|
+
done(value);
|
|
782
|
+
}, () => {
|
|
730
783
|
s.lastHint = undefined;
|
|
731
784
|
refreshBackgroundWorkNavigator(ctx);
|
|
732
785
|
}, selectedId);
|
|
733
|
-
|
|
786
|
+
overlayRef = {
|
|
787
|
+
showDetail: (navigatorId) => component.showDetail(navigatorId),
|
|
788
|
+
focus: () => {
|
|
789
|
+
if (s.overlay !== overlayRef) return;
|
|
790
|
+
try { handle?.focus?.(); } catch { /* ignore */ }
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
s.overlay = overlayRef;
|
|
794
|
+
disposeToken = () => component.dismiss();
|
|
734
795
|
s.dispose = disposeToken;
|
|
735
796
|
return component;
|
|
736
|
-
}, { overlay: true, overlayOptions: detailOverlayOptions });
|
|
797
|
+
}, { overlay: true, overlayOptions: detailOverlayOptions, onHandle: (h: typeof handle) => { handle = h; } });
|
|
737
798
|
const clear = () => {
|
|
799
|
+
release();
|
|
738
800
|
if (s.dispose === disposeToken) s.dispose = undefined;
|
|
739
801
|
};
|
|
740
802
|
void Promise.resolve(opened).then(clear, clear);
|
|
@@ -750,16 +812,16 @@ function createOverlayComponent(
|
|
|
750
812
|
theme: { fg?(color: string, value: string): string } | undefined,
|
|
751
813
|
done: (v: null) => void,
|
|
752
814
|
onClosed: () => void,
|
|
753
|
-
initialDetailId
|
|
815
|
+
initialDetailId: string,
|
|
754
816
|
) {
|
|
817
|
+
// The overlay only ever shows one row's detail; the rail below it is the list.
|
|
755
818
|
const overlayState: OverlayState = { rows: initialRows, selected: 0 };
|
|
756
|
-
let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
|
|
757
819
|
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
758
820
|
const expandedSections = new Set<string>();
|
|
759
|
-
let detailId: string
|
|
760
|
-
let detail: BackgroundWorkDetail | null =
|
|
821
|
+
let detailId: string = initialDetailId;
|
|
822
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
|
|
761
823
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
762
|
-
|
|
824
|
+
{
|
|
763
825
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
764
826
|
if (idx >= 0) overlayState.selected = idx;
|
|
765
827
|
}
|
|
@@ -776,7 +838,7 @@ function createOverlayComponent(
|
|
|
776
838
|
}
|
|
777
839
|
|
|
778
840
|
const detailScheduler = createRenderScheduler(() => {
|
|
779
|
-
if (
|
|
841
|
+
if (closed) return;
|
|
780
842
|
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
781
843
|
requestRender();
|
|
782
844
|
startDetailTimer();
|
|
@@ -837,29 +899,11 @@ function createOverlayComponent(
|
|
|
837
899
|
expandedSections.clear();
|
|
838
900
|
detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
|
|
839
901
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
840
|
-
mode = "detail";
|
|
841
902
|
startDetailTimer();
|
|
842
903
|
requestRender();
|
|
843
904
|
}
|
|
844
905
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
function leaveDetail(): void {
|
|
848
|
-
if (mode !== "detail") return;
|
|
849
|
-
const viewedId = detailId;
|
|
850
|
-
clearCloseArm();
|
|
851
|
-
stopDetailTimer();
|
|
852
|
-
mode = "list";
|
|
853
|
-
detail = null;
|
|
854
|
-
detailId = null;
|
|
855
|
-
expandedSections.clear();
|
|
856
|
-
refreshRows();
|
|
857
|
-
if (viewedId) {
|
|
858
|
-
const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
|
|
859
|
-
if (idx >= 0) overlayState.selected = idx;
|
|
860
|
-
}
|
|
861
|
-
requestRender();
|
|
862
|
-
}
|
|
906
|
+
startDetailTimer();
|
|
863
907
|
|
|
864
908
|
function close(): void {
|
|
865
909
|
if (closed) return;
|
|
@@ -871,20 +915,28 @@ function createOverlayComponent(
|
|
|
871
915
|
|
|
872
916
|
function handleCloseKey(): void {
|
|
873
917
|
const row = selectedRow();
|
|
874
|
-
if (!row) return;
|
|
918
|
+
if (!row || row.parentRow) return;
|
|
875
919
|
const now = Date.now();
|
|
876
920
|
if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
|
|
877
921
|
clearCloseArm();
|
|
922
|
+
const before = overlayState.rows;
|
|
923
|
+
const closedIdx = overlayState.selected;
|
|
878
924
|
closeFor(row);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
detail = null;
|
|
883
|
-
detailId = null;
|
|
884
|
-
}
|
|
885
|
-
refreshRows();
|
|
925
|
+
stopDetailTimer();
|
|
926
|
+
const after = listRows();
|
|
927
|
+
const nextId = neighbourCloseTarget(before, closedIdx, row.navigatorId, after);
|
|
886
928
|
onClosed();
|
|
887
|
-
|
|
929
|
+
if (!nextId) {
|
|
930
|
+
// Nothing left to show: hand the keyboard back to the editor.
|
|
931
|
+
unfocusMainList();
|
|
932
|
+
close();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
state().mainListSelectedId = nextId;
|
|
936
|
+
overlayState.rows = after;
|
|
937
|
+
overlayState.selected = Math.max(0, after.findIndex((candidate) => candidate.navigatorId === nextId));
|
|
938
|
+
refreshMainListWidget();
|
|
939
|
+
openDetail();
|
|
888
940
|
return;
|
|
889
941
|
}
|
|
890
942
|
clearCloseArm();
|
|
@@ -903,18 +955,16 @@ function createOverlayComponent(
|
|
|
903
955
|
return {
|
|
904
956
|
render(width: number) {
|
|
905
957
|
refreshRows();
|
|
906
|
-
const railLines =
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
: [];
|
|
912
|
-
const editorLines = mode === "detail" ? renderEditorLines(width) : [];
|
|
958
|
+
const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
959
|
+
selectedId: selectedRow()?.navigatorId,
|
|
960
|
+
focused: true,
|
|
961
|
+
});
|
|
962
|
+
const editorLines = renderEditorLines(width);
|
|
913
963
|
const bottomLines = [...railLines, ...editorLines];
|
|
914
964
|
const overlayRows = state().detailOverlayRows;
|
|
915
965
|
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
916
966
|
let contentLines: string[];
|
|
917
|
-
if (
|
|
967
|
+
if (detail?.transcript && deps.createTranscriptComponent) {
|
|
918
968
|
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
919
969
|
transcriptDetail = detail;
|
|
920
970
|
transcriptComponent = deps.createTranscriptComponent(detail, theme);
|
|
@@ -930,15 +980,13 @@ function createOverlayComponent(
|
|
|
930
980
|
} else {
|
|
931
981
|
transcriptDetail = null;
|
|
932
982
|
transcriptComponent = null;
|
|
933
|
-
contentLines =
|
|
934
|
-
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false })
|
|
935
|
-
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
983
|
+
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
|
|
936
984
|
}
|
|
937
|
-
if (
|
|
938
|
-
if (detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
985
|
+
if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
939
986
|
const fittedContent = contentLines.slice(0, detailRows);
|
|
940
987
|
while (fittedContent.length < detailRows) fittedContent.push("");
|
|
941
|
-
|
|
988
|
+
// A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
|
|
989
|
+
return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
|
|
942
990
|
},
|
|
943
991
|
handleInput(data: string) {
|
|
944
992
|
if (closed) return;
|
|
@@ -946,56 +994,53 @@ function createOverlayComponent(
|
|
|
946
994
|
handleCloseKey();
|
|
947
995
|
return;
|
|
948
996
|
}
|
|
949
|
-
if (
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
else expandedSections.add(sectionId);
|
|
971
|
-
requestRender();
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
else if (data === "l" || data === "L") {
|
|
975
|
-
logTailRows = cycleLogTailRows(logTailRows);
|
|
976
|
-
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
997
|
+
if (deps.matchKey(data, "up")) {
|
|
998
|
+
selectOverlayRow(overlayState.selected - 1);
|
|
999
|
+
activateSelectedRow();
|
|
1000
|
+
}
|
|
1001
|
+
else if (deps.matchKey(data, "down")) {
|
|
1002
|
+
selectOverlayRow(overlayState.selected + 1);
|
|
1003
|
+
activateSelectedRow();
|
|
1004
|
+
}
|
|
1005
|
+
else if (deps.matchKey(data, "left")) {
|
|
1006
|
+
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
1007
|
+
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
1008
|
+
close();
|
|
1009
|
+
}
|
|
1010
|
+
else if (deps.matchKey(data, "enter")) {
|
|
1011
|
+
const row = selectedRow();
|
|
1012
|
+
if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
|
|
1013
|
+
else {
|
|
1014
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
1015
|
+
if (!sectionId) return;
|
|
1016
|
+
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
1017
|
+
else expandedSections.add(sectionId);
|
|
977
1018
|
requestRender();
|
|
978
1019
|
}
|
|
979
|
-
else if (deps.matchKey(data, "escape")) {
|
|
980
|
-
unfocusMainList();
|
|
981
|
-
close();
|
|
982
|
-
}
|
|
983
|
-
return;
|
|
984
1020
|
}
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
|
|
1021
|
+
else if (data === "l" || data === "L") {
|
|
1022
|
+
logTailRows = cycleLogTailRows(logTailRows);
|
|
1023
|
+
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
988
1024
|
requestRender();
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
requestRender();
|
|
993
|
-
} else if (deps.matchKey(data, "enter")) {
|
|
994
|
-
openDetail();
|
|
995
|
-
} else if (deps.matchKey(data, "escape")) {
|
|
1025
|
+
}
|
|
1026
|
+
else if (deps.matchKey(data, "escape")) {
|
|
1027
|
+
unfocusMainList();
|
|
996
1028
|
close();
|
|
997
1029
|
}
|
|
998
1030
|
},
|
|
1031
|
+
/** Show another row's detail in this overlay (the main list moved while it was unfocused). */
|
|
1032
|
+
showDetail(navigatorId: string) {
|
|
1033
|
+
if (closed) return;
|
|
1034
|
+
refreshRows();
|
|
1035
|
+
const idx = overlayState.rows.findIndex((row) => row.navigatorId === navigatorId);
|
|
1036
|
+
if (idx < 0) return;
|
|
1037
|
+
overlayState.selected = idx;
|
|
1038
|
+
state().mainListSelectedId = navigatorId;
|
|
1039
|
+
if (overlayState.rows[idx]!.parentRow) close();
|
|
1040
|
+
else openDetail();
|
|
1041
|
+
},
|
|
1042
|
+
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1043
|
+
dismiss() { close(); },
|
|
999
1044
|
invalidate() { transcriptComponent?.invalidate(); },
|
|
1000
1045
|
dispose() {
|
|
1001
1046
|
clearCloseArm();
|
|
@@ -1004,6 +1049,30 @@ function createOverlayComponent(
|
|
|
1004
1049
|
};
|
|
1005
1050
|
}
|
|
1006
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* After a confirmed close, the row whose detail the overlay shows next: the
|
|
1054
|
+
* nearest closable row below the closed one in the pre-close order, else the
|
|
1055
|
+
* nearest above it. `undefined` means nothing closable remains.
|
|
1056
|
+
*/
|
|
1057
|
+
function neighbourCloseTarget(
|
|
1058
|
+
before: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1059
|
+
closedIdx: number,
|
|
1060
|
+
closedId: string,
|
|
1061
|
+
after: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
|
|
1062
|
+
): string | undefined {
|
|
1063
|
+
const remaining = new Set(after.filter((row) => !row.parentRow && row.navigatorId !== closedId).map((row) => row.navigatorId));
|
|
1064
|
+
for (let i = closedIdx + 1; i < before.length; i += 1) {
|
|
1065
|
+
const id = before[i]?.navigatorId;
|
|
1066
|
+
if (id && remaining.has(id)) return id;
|
|
1067
|
+
}
|
|
1068
|
+
for (let i = Math.min(closedIdx, before.length) - 1; i >= 0; i -= 1) {
|
|
1069
|
+
const id = before[i]?.navigatorId;
|
|
1070
|
+
if (id && remaining.has(id)) return id;
|
|
1071
|
+
}
|
|
1072
|
+
// Rows that appeared since the last render.
|
|
1073
|
+
return after.find((row) => remaining.has(row.navigatorId))?.navigatorId;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1007
1076
|
function buildTranscriptDetailLines(
|
|
1008
1077
|
detail: BackgroundWorkDetail,
|
|
1009
1078
|
transcriptLines: string[],
|
|
@@ -1088,33 +1157,6 @@ function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
|
1088
1157
|
};
|
|
1089
1158
|
}
|
|
1090
1159
|
|
|
1091
|
-
function buildListLines(nav: OverlayState, width: number, truncate: (s: string, width: number) => string, fg: (color: string, value: string) => string): string[] {
|
|
1092
|
-
const lines: string[] = [];
|
|
1093
|
-
lines.push(rule(`Work · ${nav.rows.length}`, width));
|
|
1094
|
-
const selected = nav.rows[nav.selected];
|
|
1095
|
-
const closeAction = selected ? "x close" : null;
|
|
1096
|
-
lines.push(dim(` ${["↑↓ select", "Enter view", closeAction, "Esc close"].filter(Boolean).join(" · ")}`, fg));
|
|
1097
|
-
lines.push("");
|
|
1098
|
-
if (nav.rows.length === 0) lines.push(" (no work)");
|
|
1099
|
-
let lastProvider = "";
|
|
1100
|
-
for (let i = 0; i < nav.rows.length; i += 1) {
|
|
1101
|
-
const row = nav.rows[i]!;
|
|
1102
|
-
if (row.providerLabel !== lastProvider) {
|
|
1103
|
-
lines.push(dim(section(row.providerLabel, width), fg));
|
|
1104
|
-
lastProvider = row.providerLabel;
|
|
1105
|
-
}
|
|
1106
|
-
const prefix = i === nav.selected ? fg("accent", "› ") : " ";
|
|
1107
|
-
const status = fg(toneColor(row.statusTone, row.status), row.status);
|
|
1108
|
-
const facts = (row.facts ?? []).filter(Boolean).slice(0, 2);
|
|
1109
|
-
const suffix = facts.length ? ` · ${facts.join(" · ")}` : "";
|
|
1110
|
-
lines.push(`${prefix}${row.name || row.id} · ${row.kind} · ${row.elapsed} · ${row.primary} · ${status}${suffix}`);
|
|
1111
|
-
if (row.secondary) lines.push(` ${row.secondary}`);
|
|
1112
|
-
}
|
|
1113
|
-
lines.push("");
|
|
1114
|
-
lines.push(dim(rule("", width), fg));
|
|
1115
|
-
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
1160
|
function buildDetailLines(
|
|
1119
1161
|
detail: BackgroundWorkDetail | null,
|
|
1120
1162
|
width: number,
|
|
@@ -1173,7 +1215,9 @@ function buildDetailLines(
|
|
|
1173
1215
|
lines.push(` ${preview} ${folded}`);
|
|
1174
1216
|
} else {
|
|
1175
1217
|
const wrapped = wrapEvidenceText(body, width - 6);
|
|
1176
|
-
|
|
1218
|
+
let shown = wrapped.slice(0, tailRows);
|
|
1219
|
+
// Same budget as the tail branch: the header row and the trailing blank come out of maxRows too.
|
|
1220
|
+
if (options.maxRows !== undefined) shown = shown.slice(0, Math.max(1, options.maxRows - lines.length - 2));
|
|
1177
1221
|
lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
|
|
1178
1222
|
for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
|
|
1179
1223
|
}
|
|
@@ -414,13 +414,22 @@ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): s
|
|
|
414
414
|
|
|
415
415
|
/**
|
|
416
416
|
* Every symlink directory entry met while resolving `path`, component by
|
|
417
|
-
* component (as `readlink` would follow them)
|
|
417
|
+
* component (as `readlink` would follow them), plus where a dangling chain
|
|
418
|
+
* would land. A chain such as
|
|
418
419
|
* `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
|
|
419
420
|
* in a removable directory; replacing it would retarget the protected entry, so
|
|
420
|
-
* each hop the user could replace is protected too.
|
|
421
|
-
*
|
|
421
|
+
* each hop the user could replace is protected too. When a followed link
|
|
422
|
+
* points at something missing, creating it would give the entry content, so
|
|
423
|
+
* the first missing component (which covers every missing directory below it)
|
|
424
|
+
* and the full path it would resolve to are protected as well. Bounded: a loop
|
|
425
|
+
* or more than 40 hops stops the walk (such a path resolves to nothing).
|
|
422
426
|
*/
|
|
423
427
|
function symlinkHops(path: string): string[] {
|
|
428
|
+
const { hops, unresolved } = walkSymlinks(path);
|
|
429
|
+
return [...hops, ...unresolved];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function walkSymlinks(path: string): { hops: string[]; unresolved: string[] } {
|
|
424
433
|
const hops: string[] = [];
|
|
425
434
|
const seen = new Set<string>();
|
|
426
435
|
let followed = 0;
|
|
@@ -433,7 +442,13 @@ function symlinkHops(path: string): string[] {
|
|
|
433
442
|
if (component === "..") { current = dirname(current); continue; }
|
|
434
443
|
const entry = join(current, component);
|
|
435
444
|
let link: boolean;
|
|
436
|
-
try {
|
|
445
|
+
try {
|
|
446
|
+
link = lstatSync(entry).isSymbolicLink();
|
|
447
|
+
} catch {
|
|
448
|
+
// Missing. Only a followed link makes this a dangling target; a
|
|
449
|
+
// plain absent path is guarded as it is.
|
|
450
|
+
return { hops, unresolved: followed ? [...new Set([entry, resolve(entry, ...pending)])] : [] };
|
|
451
|
+
}
|
|
437
452
|
if (!link) { current = entry; continue; }
|
|
438
453
|
if (++followed > 40 || seen.has(entry)) break;
|
|
439
454
|
seen.add(entry);
|
|
@@ -448,7 +463,12 @@ function symlinkHops(path: string): string[] {
|
|
|
448
463
|
pending = [...target.split(sep), ...pending];
|
|
449
464
|
}
|
|
450
465
|
}
|
|
451
|
-
return hops;
|
|
466
|
+
return { hops, unresolved: [] };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Whether a symlink resolves to nothing: dangling (ENOENT), or looping or over 40 hops (ELOOP). */
|
|
470
|
+
function unresolvable(path: string): boolean {
|
|
471
|
+
try { statSync(path); return false; } catch { return true; }
|
|
452
472
|
}
|
|
453
473
|
|
|
454
474
|
function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
|
|
@@ -1296,7 +1316,10 @@ function maskSources(profilePath: string): { directory: string; file: string } {
|
|
|
1296
1316
|
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1297
1317
|
* place there. Credentials are masked by empty mode-000 mounts; code that runs
|
|
1298
1318
|
* later and caller-denied paths are read-only binds. Every writable bind and
|
|
1299
|
-
* protected leaf is a mount point, which Linux refuses to rename.
|
|
1319
|
+
* protected leaf is a mount point, which Linux refuses to rename. A protected
|
|
1320
|
+
* symlink inside a writable directory (a stow link in `~/.config`) makes that
|
|
1321
|
+
* directory read-only with each existing entry bound writable again, so only
|
|
1322
|
+
* new top-level entries there are refused.
|
|
1300
1323
|
*/
|
|
1301
1324
|
function buildLinuxBroadCommand(
|
|
1302
1325
|
bwrap: string,
|
|
@@ -1347,15 +1370,38 @@ function buildLinuxBroadCommand(
|
|
|
1347
1370
|
const readOnly = new Set<string>();
|
|
1348
1371
|
const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
|
|
1349
1372
|
.some((name) => canonicalizePath(join(broad.home, name), seams) === path);
|
|
1373
|
+
// Directories holding a protected symlink: read-only, so the link cannot be
|
|
1374
|
+
// replaced, with their existing entries bound writable again below.
|
|
1375
|
+
const linkParents = new Set<string>();
|
|
1376
|
+
const danglingTargets = new Set(protectedPaths.flatMap((path) => walkSymlinks(path).unresolved));
|
|
1350
1377
|
const guard = (path: string, hide: boolean) => {
|
|
1351
1378
|
let directory: boolean | undefined;
|
|
1352
|
-
|
|
1379
|
+
let link = false;
|
|
1380
|
+
try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
|
|
1381
|
+
if (canonicalizePath(path, seams) !== path || (link && unresolvable(path))) {
|
|
1353
1382
|
// A literal twin of a canonical deny entry: its target is guarded on
|
|
1354
|
-
// its own. A
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
if (link && dirname(path) !== broad.home && writableAt(dirname(path)))
|
|
1383
|
+
// its own. A looping link resolves to nothing (ELOOP), and a dangling
|
|
1384
|
+
// one's missing target is guarded below, so only the link itself
|
|
1385
|
+
// needs guarding here. A symlink leaf inside a writable root could be
|
|
1386
|
+
// replaced, so its directory is protected; home already is.
|
|
1387
|
+
if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (danglingTargets.has(path)) {
|
|
1391
|
+
// Where a dangling link would land: nothing may be created there.
|
|
1392
|
+
// Lock its nearest existing ancestor (entries stay writable, see
|
|
1393
|
+
// below) rather than plant a placeholder in the user's files. Temp
|
|
1394
|
+
// and the workspace root stay open to new entries, so a placeholder
|
|
1395
|
+
// is bound there instead, failing closed if it cannot be made.
|
|
1396
|
+
if (!writableAt(path)) return;
|
|
1397
|
+
let ancestor = dirname(path);
|
|
1398
|
+
while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
|
|
1399
|
+
if (ancestor === project || broad.tempRoots.includes(ancestor)) {
|
|
1400
|
+
if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
|
|
1401
|
+
readOnly.add(path);
|
|
1402
|
+
} else if (ancestor !== broad.home) {
|
|
1403
|
+
linkParents.add(ancestor);
|
|
1404
|
+
}
|
|
1359
1405
|
return;
|
|
1360
1406
|
}
|
|
1361
1407
|
if (!exists(path)) {
|
|
@@ -1384,19 +1430,37 @@ function buildLinuxBroadCommand(
|
|
|
1384
1430
|
for (const path of writeDenied) guard(path, false);
|
|
1385
1431
|
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1386
1432
|
|
|
1433
|
+
// A read-only link parent keeps its existing entries writable: each one is
|
|
1434
|
+
// bound back read-write (a mount point, so it cannot be renamed away either).
|
|
1435
|
+
// Only new top-level entries in that directory are lost. Protected entries
|
|
1436
|
+
// keep their own mounts, and a symlink entry is never bound: a bind follows
|
|
1437
|
+
// it, and writes through it already meet its target's own mounts.
|
|
1438
|
+
const rebound = new Set<string>();
|
|
1439
|
+
for (const parent of linkParents) readOnly.add(parent);
|
|
1440
|
+
for (const parent of linkParents) {
|
|
1441
|
+
for (const entry of list(parent)) {
|
|
1442
|
+
if (entry.symlink) continue;
|
|
1443
|
+
const path = join(parent, entry.name);
|
|
1444
|
+
if (insideProtected(path) || readOnly.has(path) || writable.has(path)) continue;
|
|
1445
|
+
rebound.add(path);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1387
1449
|
// Intermediate directories between a writable root and anything bound
|
|
1388
1450
|
// inside it become mount points too, so none of them can be renamed away.
|
|
1389
1451
|
const anchors = new Set<string>();
|
|
1390
1452
|
for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
|
|
1391
1453
|
for (const parent of protectedAncestors([target])) {
|
|
1392
1454
|
if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
|
|
1393
|
-
!readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
|
|
1455
|
+
!readOnly.has(parent) && !writable.has(parent) && !rebound.has(parent)) anchors.add(parent);
|
|
1394
1456
|
}
|
|
1395
1457
|
}
|
|
1396
|
-
type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
|
|
1458
|
+
type Mount = { option: "--bind" | "--bind-try" | "--ro-bind"; source: string; path: string; rank: number };
|
|
1397
1459
|
const mounts: Mount[] = [
|
|
1398
1460
|
...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
|
|
1399
1461
|
...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
|
|
1462
|
+
// `-try`: an entry removed before a resumed launch stays read-only instead of failing it.
|
|
1463
|
+
...[...rebound].map((path) => ({ option: "--bind-try" as const, source: path, path, rank: 1 })),
|
|
1400
1464
|
...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
|
|
1401
1465
|
...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
|
|
1402
1466
|
].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
|
package/src/tools.ts
CHANGED
|
@@ -71,7 +71,7 @@ const RemoteSchema = Type.Object({
|
|
|
71
71
|
// TypeBox union, and a Null-first union reports a malformed array as "must be null".
|
|
72
72
|
const IntentFields = {
|
|
73
73
|
operation_id: Type.Optional(Type.Unsafe<string | null>({ anyOf: [{ type: "null" }, { type: "string" }], description: "Optional stable id (letters, digits, . _ : / -; up to 64) for one logical operation. Reuse it when you retry with a changed command, scope, or timeout: when a later task with the same operation_id succeeds, earlier failures of that operation in this session are recovered." })),
|
|
74
|
-
expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum:
|
|
74
|
+
expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
const CommandFields = {
|