pi-better-harness 0.7.0 → 0.8.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/node_modules/pi-better-background-tasks/README.md +6 -4
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/failures.ts +9 -1
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +9 -6
- package/node_modules/pi-better-background-tasks/src/output.ts +5 -1
- package/node_modules/pi-better-background-tasks/src/runtime.ts +25 -9
- package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +48 -12
- package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +6 -12
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +169 -125
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +78 -14
- package/node_modules/pi-better-background-tasks/src/tools.ts +1 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-sandbox/README.md +32 -6
- package/node_modules/pi-better-sandbox/index.ts +4 -0
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +74 -7
- package/node_modules/pi-better-sandbox/permissions.ts +13 -2
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +78 -14
- package/node_modules/pi-better-sandbox/shared-task-apply-patch.ts +409 -0
- package/node_modules/pi-better-sandbox/shared-task-files.ts +69 -7
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +11 -0
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +111 -0
- package/node_modules/pi-better-sandbox/state.ts +5 -1
- package/node_modules/pi-better-subagents/README.md +1 -1
- package/node_modules/pi-better-subagents/child-incidents.ts +2 -2
- package/node_modules/pi-better-subagents/docs/failure-observations.md +1 -1
- package/node_modules/pi-better-subagents/failures.ts +48 -6
- package/node_modules/pi-better-subagents/incident-model.ts +11 -4
- package/node_modules/pi-better-subagents/index.ts +47 -18
- package/node_modules/pi-better-subagents/output-payload.ts +2 -3
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/permission-policy.ts +9 -3
- package/node_modules/pi-better-subagents/registry.ts +19 -2
- package/node_modules/pi-better-subagents/shared-failure-observations.ts +48 -12
- package/node_modules/pi-better-subagents/shared-log-utils.ts +6 -12
- package/node_modules/pi-better-subagents/shared-navigator.ts +169 -125
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +78 -14
- package/node_modules/pi-better-subagents/shared-task-apply-patch.ts +409 -0
- package/node_modules/pi-better-subagents/shared-task-files.ts +69 -7
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +11 -0
- package/node_modules/pi-better-subagents/shared-task-tools.ts +111 -0
- package/node_modules/pi-better-subagents/subagent-tools.ts +95 -0
- package/node_modules/pi-better-subagents/task-guard.ts +42 -7
- package/node_modules/pi-better-subagents/task-policy.ts +28 -0
- package/package.json +4 -4
|
@@ -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
|
|
@@ -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);
|
|
@@ -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.`] : []),
|
|
@@ -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
|
}
|
|
@@ -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) {
|