pi-crew 0.11.49 → 0.11.51
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/CHANGELOG.md +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/broker/crew-broker-child.ts +55 -17
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- package/src/worktree/worktree-manager.ts +34 -4
|
@@ -184,6 +184,176 @@ function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTa
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* N3 (security-audit run-1, 2026-10-29 — w9 loader-level hardening):
|
|
189
|
+
* tasks.json and manifest.json live under the shared-cwd state root, i.e. they
|
|
190
|
+
* are same-uid writable by WRITE-role workers (verify-gate.ts treats shared-cwd
|
|
191
|
+
* files as worker-mutable attacker input for exactly this reason). Before this
|
|
192
|
+
* fix the load seam cast whatever JSON.parse produced straight to
|
|
193
|
+
* TeamTaskState[]/TeamRunManifest, so poisoned fields flowed into resume /
|
|
194
|
+
* crash-recovery dispatch as execution authority (forged role →
|
|
195
|
+
* permissionForRole at pre-execution.ts:248; forged runKind selects the resume
|
|
196
|
+
* engine at team-tool.ts:673). The validators below are the bounded,
|
|
197
|
+
* backward-compatible answer: fail-closed on structurally poisoned entries
|
|
198
|
+
* (drop, never crash the load), permissive on well-formed legacy state.
|
|
199
|
+
* Provenance/integrity (HMAC) and role re-authorization vs team composition
|
|
200
|
+
* are explicitly deferred (need secret-management + dispatch-side context).
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
/** N3: loader DoG guard — a tasks array beyond this cap is truncated with an
|
|
204
|
+
* anomaly log. The maxTasksPerRun admission gate (state-store) caps dispatch
|
|
205
|
+
* cardinality; this bound merely stops an inflated file from dominating load
|
|
206
|
+
* time / memory before admission ever runs. 10_000 mirrors the default
|
|
207
|
+
* limits.maxTasksPerRun ceiling. */
|
|
208
|
+
export const MAX_LOADED_TASKS = 10_000;
|
|
209
|
+
|
|
210
|
+
const MAX_ANOMALY_EXAMPLES = 5;
|
|
211
|
+
|
|
212
|
+
/** N3: per-entry shape check — a plausible TeamTaskState is a non-null,
|
|
213
|
+
* non-array object whose id/runId/role/agent/title/status are strings, whose
|
|
214
|
+
* dependsOn is absent or an array of strings, and whose cwd is absent or a
|
|
215
|
+
* string. Deliberately shape-only: values (role names, cwd targets, status
|
|
216
|
+
* enums) are NOT re-authorized here — the loader has no dispatch-side context
|
|
217
|
+
* for what the approved composition was. */
|
|
218
|
+
function plausibleTaskShape(entry: unknown): entry is TeamTaskState {
|
|
219
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
220
|
+
const task = entry as Record<string, unknown>;
|
|
221
|
+
if (typeof task.id !== "string") return false;
|
|
222
|
+
if (typeof task.runId !== "string") return false;
|
|
223
|
+
if (typeof task.role !== "string") return false;
|
|
224
|
+
if (typeof task.agent !== "string") return false;
|
|
225
|
+
if (typeof task.title !== "string") return false;
|
|
226
|
+
if (typeof task.status !== "string") return false;
|
|
227
|
+
if (task.dependsOn !== undefined) {
|
|
228
|
+
if (!Array.isArray(task.dependsOn) || task.dependsOn.some((dep) => typeof dep !== "string")) return false;
|
|
229
|
+
}
|
|
230
|
+
if (task.cwd !== undefined && typeof task.cwd !== "string") return false;
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* N3: validate a tasks array loaded from disk before it re-enters dispatch.
|
|
236
|
+
*
|
|
237
|
+
* - Entries failing the plausible-shape check are DROPPED with an anomaly log
|
|
238
|
+
* (fail-closed on poison) instead of crashing the load or leaking through.
|
|
239
|
+
* - Entries whose runId does not match the loading run's runId are DROPPED —
|
|
240
|
+
* mirrors the w3 DwfStore.load(expectedRunId) binding in
|
|
241
|
+
* dwf-state-store.ts: a foreign/stale/planted entry never hydrates as this
|
|
242
|
+
* run's execution authority.
|
|
243
|
+
* - Arrays longer than {@link MAX_LOADED_TASKS} are truncated (DoG guard).
|
|
244
|
+
*
|
|
245
|
+
* Clean input is returned UNCHANGED (same array reference, same entry objects)
|
|
246
|
+
* so legitimate legacy v0/v1 state loads byte-identical.
|
|
247
|
+
*/
|
|
248
|
+
/** @internal — exported for tests pinning the N3 loader contract. */
|
|
249
|
+
export function sanitizeLoadedTasks(tasks: TeamTaskState[], runId: string): TeamTaskState[] {
|
|
250
|
+
const truncated = tasks.length > MAX_LOADED_TASKS;
|
|
251
|
+
const limit = truncated ? MAX_LOADED_TASKS : tasks.length;
|
|
252
|
+
const kept: TeamTaskState[] = [];
|
|
253
|
+
const shapeInvalid: string[] = [];
|
|
254
|
+
const foreignRunId: string[] = [];
|
|
255
|
+
for (let i = 0; i < limit; i++) {
|
|
256
|
+
const entry: unknown = tasks[i];
|
|
257
|
+
if (!plausibleTaskShape(entry)) {
|
|
258
|
+
const id = entry !== null && typeof entry === "object" && !Array.isArray(entry) ? (entry as { id?: unknown }).id : undefined;
|
|
259
|
+
shapeInvalid.push(typeof id === "string" ? id : `#${i}`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// RunId binding (entry.runId is a string — the shape check above already
|
|
263
|
+
// failed entries without one, so legacy entries missing runId cannot
|
|
264
|
+
// masquerade as this run's tasks either).
|
|
265
|
+
if (entry.runId !== runId) {
|
|
266
|
+
foreignRunId.push(entry.id);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
kept.push(entry);
|
|
270
|
+
}
|
|
271
|
+
if (truncated) {
|
|
272
|
+
logInternalError(
|
|
273
|
+
"state-store",
|
|
274
|
+
new Error(
|
|
275
|
+
`N3: tasks.json for run ${runId} holds ${tasks.length} entries — beyond the ${MAX_LOADED_TASKS} loader cap. Truncated to the first ${MAX_LOADED_TASKS} (dispatch admission caps are enforced separately).`,
|
|
276
|
+
),
|
|
277
|
+
undefined,
|
|
278
|
+
"error",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (shapeInvalid.length > 0) {
|
|
282
|
+
logInternalError(
|
|
283
|
+
"state-store",
|
|
284
|
+
new Error(
|
|
285
|
+
`N3: ${shapeInvalid.length} task entr${shapeInvalid.length === 1 ? "y" : "ies"} in tasks.json for run ${runId} failed shape validation and ${shapeInvalid.length === 1 ? "was" : "were"} DROPPED (worker-writable state is untrusted input; example id(s): ${shapeInvalid.slice(0, MAX_ANOMALY_EXAMPLES).join(",")}).`,
|
|
286
|
+
),
|
|
287
|
+
undefined,
|
|
288
|
+
"error",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (foreignRunId.length > 0) {
|
|
292
|
+
logInternalError(
|
|
293
|
+
"state-store",
|
|
294
|
+
new Error(
|
|
295
|
+
`N3: ${foreignRunId.length} task entr${foreignRunId.length === 1 ? "y" : "ies"} in tasks.json for run ${runId} carry a foreign runId and ${foreignRunId.length === 1 ? "was" : "were"} DROPPED (runId binding, mirrors DwfStore.load; example id(s): ${foreignRunId.slice(0, MAX_ANOMALY_EXAMPLES).join(",")}).`,
|
|
296
|
+
),
|
|
297
|
+
undefined,
|
|
298
|
+
"error",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return truncated || shapeInvalid.length > 0 || foreignRunId.length > 0 ? kept : tasks;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** N3: the run kinds a persisted manifest may legally select. Unknown values
|
|
305
|
+
* normalize to "team-run" (see sanitizeLoadedManifest) — matching the
|
|
306
|
+
* `manifest.runKind ?? "team-run"` consumer at team-tool.ts:673, so a poisoned
|
|
307
|
+
* runKind can only ever select the DEFAULT resume engine. */
|
|
308
|
+
const KNOWN_RUN_KINDS = new Set<string>(["team-run", "goal-loop", "dynamic-workflow"]);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* N3: validate a manifest loaded from disk before it re-enters dispatch.
|
|
312
|
+
*
|
|
313
|
+
* - A payload that is not a JSON object at all (null/number/string/array) is
|
|
314
|
+
* NOT a manifest → undefined (fail-closed; previously the raw cast was
|
|
315
|
+
* returned and downstream field access was undefined behavior).
|
|
316
|
+
* - runKind is checked against {@link KNOWN_RUN_KINDS}: unknown values are
|
|
317
|
+
* normalized to "team-run" with a warn anomaly — deliberately NOT a throw,
|
|
318
|
+
* so poison degrades to the default engine instead of hiding the run.
|
|
319
|
+
* Absent runKind (legacy manifests) passes through unchanged: the consumer
|
|
320
|
+
* already defaults it.
|
|
321
|
+
* - Identity fields (runId/stateRoot/tasksPath/eventsPath/artifactsRoot) are
|
|
322
|
+
* intentionally NOT re-checked here — validateRunManifestPaths
|
|
323
|
+
* (state-store.ts) already owns that contract.
|
|
324
|
+
*
|
|
325
|
+
* Clean input is returned UNCHANGED (same object reference); normalization
|
|
326
|
+
* returns a shallow copy so parsed/cached objects are never mutated in place.
|
|
327
|
+
*/
|
|
328
|
+
/** @internal — exported for tests pinning the N3 loader contract. */
|
|
329
|
+
export function sanitizeLoadedManifest(manifest: TeamRunManifest | undefined, runId: string): TeamRunManifest | undefined {
|
|
330
|
+
if (manifest === undefined) return undefined;
|
|
331
|
+
const raw: unknown = manifest;
|
|
332
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
333
|
+
logInternalError(
|
|
334
|
+
"state-store",
|
|
335
|
+
new Error(
|
|
336
|
+
`N3: manifest.json for run ${runId} parsed to a non-object — not a manifest, failing closed (run treated as corrupt/missing).`,
|
|
337
|
+
),
|
|
338
|
+
undefined,
|
|
339
|
+
"error",
|
|
340
|
+
);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const kind: unknown = (raw as { runKind?: unknown }).runKind;
|
|
344
|
+
if (kind === undefined) return manifest;
|
|
345
|
+
if (typeof kind === "string" && KNOWN_RUN_KINDS.has(kind)) return manifest;
|
|
346
|
+
logInternalError(
|
|
347
|
+
"state-store",
|
|
348
|
+
new Error(
|
|
349
|
+
`N3: manifest runKind ${JSON.stringify(kind)} for run ${runId} is not a known run kind (team-run|goal-loop|dynamic-workflow) — normalizing to 'team-run' so only the default resume engine can be selected.`,
|
|
350
|
+
),
|
|
351
|
+
undefined,
|
|
352
|
+
"warn",
|
|
353
|
+
);
|
|
354
|
+
return { ...manifest, runKind: "team-run" };
|
|
355
|
+
}
|
|
356
|
+
|
|
187
357
|
/**
|
|
188
358
|
* ST-4: Load tasks.json with corruption recovery (sync path).
|
|
189
359
|
*
|
|
@@ -253,6 +423,11 @@ function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
|
|
|
253
423
|
const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
|
|
254
424
|
const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
|
|
255
425
|
if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
|
|
426
|
+
// N3 note: schemaVersion mismatch stays WARN-ONLY deliberately —
|
|
427
|
+
// forward compatibility is a design requirement (a newer-but-
|
|
428
|
+
// well-formed tasks.json must still load). Field-level poison is
|
|
429
|
+
// handled by sanitizeLoadedTasks (shape + runId binding + count cap)
|
|
430
|
+
// downstream, NOT by version rejection.
|
|
256
431
|
logInternalError(
|
|
257
432
|
"state-store",
|
|
258
433
|
new Error(
|
|
@@ -279,17 +454,20 @@ export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, run
|
|
|
279
454
|
parsed = JSON.parse(content);
|
|
280
455
|
} catch {
|
|
281
456
|
// SyntaxError — corrupt file.
|
|
282
|
-
const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
|
|
457
|
+
const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
|
|
283
458
|
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
284
459
|
return reconstructed;
|
|
285
460
|
}
|
|
286
461
|
if (!isRecognizableTasksPayload(parsed)) {
|
|
287
462
|
// Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
|
|
288
|
-
const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
|
|
463
|
+
const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
|
|
289
464
|
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
290
465
|
return reconstructed;
|
|
291
466
|
}
|
|
292
|
-
|
|
467
|
+
// N3: fail-closed validation of the migrated array before it re-enters
|
|
468
|
+
// dispatch (shape-check + runId binding + count cap) — see
|
|
469
|
+
// sanitizeLoadedTasks above.
|
|
470
|
+
return sanitizeLoadedTasks(migrateTasksFile(parsed, runId), runId);
|
|
293
471
|
}
|
|
294
472
|
|
|
295
473
|
/**
|
|
@@ -309,8 +487,9 @@ export function loadManifestWithRecovery(manifestPath: string, runId: string): T
|
|
|
309
487
|
// ENOENT / ENOTDIR / other read error → legitimate missing run.
|
|
310
488
|
return undefined;
|
|
311
489
|
}
|
|
490
|
+
let parsed: unknown;
|
|
312
491
|
try {
|
|
313
|
-
|
|
492
|
+
parsed = JSON.parse(content);
|
|
314
493
|
} catch {
|
|
315
494
|
// SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
|
|
316
495
|
// then treat as missing. Do NOT reconstruct (infeasible from events).
|
|
@@ -325,6 +504,25 @@ export function loadManifestWithRecovery(manifestPath: string, runId: string): T
|
|
|
325
504
|
);
|
|
326
505
|
return undefined;
|
|
327
506
|
}
|
|
507
|
+
// N3: a payload that parses but is not a JSON object (null/number/string/
|
|
508
|
+
// array) is not a manifest — fail closed the same way as unparseable
|
|
509
|
+
// (quarantine for diagnosis, run treated as missing).
|
|
510
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
511
|
+
quarantineCorruptFile(manifestPath);
|
|
512
|
+
logInternalError(
|
|
513
|
+
"state-store",
|
|
514
|
+
new Error(
|
|
515
|
+
`STATE-3/N3: manifest.json for run ${runId} is not a JSON object — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
|
|
516
|
+
),
|
|
517
|
+
undefined,
|
|
518
|
+
"error",
|
|
519
|
+
);
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
// N3: runKind allowlist — unknown values normalize to "team-run" with a
|
|
523
|
+
// warn anomaly (never a throw), matching the `?? "team-run"` consumer at
|
|
524
|
+
// team-tool.ts:673.
|
|
525
|
+
return sanitizeLoadedManifest(parsed as TeamRunManifest, runId);
|
|
328
526
|
}
|
|
329
527
|
|
|
330
528
|
/**
|
|
@@ -342,14 +540,15 @@ export async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath:
|
|
|
342
540
|
try {
|
|
343
541
|
parsed = JSON.parse(content);
|
|
344
542
|
} catch {
|
|
345
|
-
const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
|
|
543
|
+
const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
|
|
346
544
|
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
347
545
|
return reconstructed;
|
|
348
546
|
}
|
|
349
547
|
if (!isRecognizableTasksPayload(parsed)) {
|
|
350
|
-
const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
|
|
548
|
+
const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
|
|
351
549
|
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
352
550
|
return reconstructed;
|
|
353
551
|
}
|
|
354
|
-
|
|
552
|
+
// N3: same fail-closed validation as the sync twin.
|
|
553
|
+
return sanitizeLoadedTasks(migrateTasksFile(parsed, runId), runId);
|
|
355
554
|
}
|
|
@@ -26,7 +26,13 @@ import { appendEvent } from "../event-log/event-log.ts";
|
|
|
26
26
|
import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
27
27
|
import { CURRENT_SCHEMA_VERSION } from "../types.ts";
|
|
28
28
|
import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
extractTaskArray,
|
|
31
|
+
loadManifestWithRecovery,
|
|
32
|
+
loadTasksWithRecovery,
|
|
33
|
+
loadTasksWithRecoveryAsync,
|
|
34
|
+
sanitizeLoadedManifest,
|
|
35
|
+
} from "./manifest-io.ts";
|
|
30
36
|
import { closeSqliteRunStateStore, getSqliteRunStateStore, isSqliteStateBackend, sqliteDbPath } from "./sqlite-run-state.ts";
|
|
31
37
|
|
|
32
38
|
export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
|
|
@@ -436,15 +442,20 @@ export function createTasksFromWorkflow(
|
|
|
436
442
|
}
|
|
437
443
|
|
|
438
444
|
/**
|
|
439
|
-
* Admission cap for
|
|
445
|
+
* Admission cap for dispatchable task sets: the effective
|
|
440
446
|
* `limits.maxTasksPerRun` from loaded config, falling back to the hard
|
|
441
447
|
* ceiling LIMIT_CEILINGS.maxTasksPerRun — no parsed config value can exceed
|
|
442
448
|
* the ceiling (config-validation clamps it), so a manifest beyond the ceiling
|
|
443
449
|
* is invalid regardless of config. Config-load failure degrades to the
|
|
444
450
|
* ceiling only (an admission check must never itself block run creation on a
|
|
445
451
|
* config read problem; loadConfig surfaces its own warnings separately).
|
|
452
|
+
*
|
|
453
|
+
* Exported (resume-skips-maxtasks-admission fix) so non-creation paths that
|
|
454
|
+
* reconstruct a dispatchable task set from PERSISTED state — team resume,
|
|
455
|
+
* retry-then-resume, special-kind adoption — can re-run the SAME invariant
|
|
456
|
+
* createRunManifest enforces, before re-queueing or dispatching.
|
|
446
457
|
*/
|
|
447
|
-
function resolveMaxTasksPerRunAdmissionCap(cwd: string): number {
|
|
458
|
+
export function resolveMaxTasksPerRunAdmissionCap(cwd: string): number {
|
|
448
459
|
try {
|
|
449
460
|
const configured = loadConfig(cwd).config.limits?.maxTasksPerRun;
|
|
450
461
|
return typeof configured === "number" && Number.isFinite(configured) ? configured : LIMIT_CEILINGS.maxTasksPerRun;
|
|
@@ -1204,18 +1215,22 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1204
1215
|
if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
|
|
1205
1216
|
const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
|
|
1206
1217
|
if (loaded) {
|
|
1207
|
-
|
|
1218
|
+
// N3: same manifest sanitization as the JSON path below (uniform
|
|
1219
|
+
// loader invariant — runKind leaving a loader is always allowlisted).
|
|
1220
|
+
const sanitizedManifest = sanitizeLoadedManifest(loaded.manifest, runId);
|
|
1221
|
+
if (!sanitizedManifest) return undefined;
|
|
1222
|
+
if (sanitizedManifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1208
1223
|
logInternalError(
|
|
1209
1224
|
"state-store",
|
|
1210
1225
|
new Error(
|
|
1211
|
-
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${
|
|
1226
|
+
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${sanitizedManifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
1212
1227
|
),
|
|
1213
1228
|
undefined,
|
|
1214
1229
|
"warn",
|
|
1215
1230
|
);
|
|
1216
1231
|
}
|
|
1217
|
-
if (!validateRunManifestPaths(cwd, runId,
|
|
1218
|
-
return { manifest:
|
|
1232
|
+
if (!validateRunManifestPaths(cwd, runId, sanitizedManifest, stateRoot, tasksPath)) return undefined;
|
|
1233
|
+
return { manifest: sanitizedManifest, tasks: loaded.tasks };
|
|
1219
1234
|
}
|
|
1220
1235
|
// No manifest row yet: either a json-created legacy run (mirror exists,
|
|
1221
1236
|
// JSON path below serves it) or a pre-birth crash (nothing anywhere —
|
|
@@ -1344,8 +1359,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1344
1359
|
// — the final stable state is what gets used. Because the retry loop
|
|
1345
1360
|
// handles the crash case, we do NOT fail based on this comparison alone.
|
|
1346
1361
|
// It does not indicate corruption on its own.
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1362
|
+
// N3: sanitize the disk-loaded manifest (non-object payload → fail closed;
|
|
1363
|
+
// runKind allowlist → unknown normalizes to "team-run" with a warn).
|
|
1364
|
+
manifest = sanitizeLoadedManifest(manifest, runId);
|
|
1365
|
+
// S-01 + N3 note: warn (do not throw) on schemaVersion mismatch — future
|
|
1366
|
+
// version bumps will add migration logic here. WARN-ONLY is deliberate
|
|
1367
|
+
// forward compat (a newer-but-well-formed manifest must still load;
|
|
1368
|
+
// field-level poison is handled by the N3 sanitizers, not version rejection).
|
|
1349
1369
|
if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1350
1370
|
logInternalError(
|
|
1351
1371
|
"state-store",
|
|
@@ -1401,18 +1421,22 @@ export async function loadRunManifestByIdAsync(
|
|
|
1401
1421
|
if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
|
|
1402
1422
|
const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
|
|
1403
1423
|
if (loaded) {
|
|
1404
|
-
|
|
1424
|
+
// N3: same manifest sanitization as the JSON path below (uniform
|
|
1425
|
+
// loader invariant — runKind leaving a loader is always allowlisted).
|
|
1426
|
+
const sanitizedManifest = sanitizeLoadedManifest(loaded.manifest, runId);
|
|
1427
|
+
if (!sanitizedManifest) return undefined;
|
|
1428
|
+
if (sanitizedManifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1405
1429
|
logInternalError(
|
|
1406
1430
|
"state-store",
|
|
1407
1431
|
new Error(
|
|
1408
|
-
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${
|
|
1432
|
+
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${sanitizedManifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
1409
1433
|
),
|
|
1410
1434
|
undefined,
|
|
1411
1435
|
"warn",
|
|
1412
1436
|
);
|
|
1413
1437
|
}
|
|
1414
|
-
if (!validateRunManifestPaths(cwd, runId,
|
|
1415
|
-
return { manifest:
|
|
1438
|
+
if (!validateRunManifestPaths(cwd, runId, sanitizedManifest, stateRoot, tasksPath)) return undefined;
|
|
1439
|
+
return { manifest: sanitizedManifest, tasks: loaded.tasks };
|
|
1416
1440
|
}
|
|
1417
1441
|
// No manifest row yet — fall through to the JSON path (legacy run or
|
|
1418
1442
|
// pre-birth crash; see the sync twin above).
|
|
@@ -1533,8 +1557,13 @@ export async function loadRunManifestByIdAsync(
|
|
|
1533
1557
|
// handles the crash case, we do NOT fail based on this comparison alone.
|
|
1534
1558
|
// It does not indicate corruption on its own.
|
|
1535
1559
|
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1560
|
+
// N3: sanitize the disk-loaded manifest (non-object payload → fail closed;
|
|
1561
|
+
// runKind allowlist → unknown normalizes to "team-run" with a warn).
|
|
1562
|
+
manifest = sanitizeLoadedManifest(manifest, runId);
|
|
1563
|
+
// S-01 + N3 note: warn (do not throw) on schemaVersion mismatch — future
|
|
1564
|
+
// version bumps will add migration logic here. WARN-ONLY is deliberate
|
|
1565
|
+
// forward compat (a newer-but-well-formed manifest must still load;
|
|
1566
|
+
// field-level poison is handled by the N3 sanitizers, not version rejection).
|
|
1538
1567
|
if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1539
1568
|
logInternalError(
|
|
1540
1569
|
"state-store",
|
package/src/state/types.ts
CHANGED
|
@@ -426,6 +426,15 @@ export interface TeamRunManifest {
|
|
|
426
426
|
};
|
|
427
427
|
/** Background dispatch discriminator. Default "team-run" runs executeTeamRun; "goal-loop" / "dynamic-workflow" dispatch to their respective runners. Absent = "team-run" for backward compatibility. */
|
|
428
428
|
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
429
|
+
/** w3-dwf-trust (2026-10-29): dispatch-time code pin for runKind="dynamic-workflow"
|
|
430
|
+
* runs. The dispatcher stamps the RESOLVED .dwf.ts script path plus the sha256 of
|
|
431
|
+
* its bytes AT DISPATCH; runDynamicWorkflow re-hashes the file before every load
|
|
432
|
+
* (fresh dispatch and both resume arms funnel through it) and refuses to execute
|
|
433
|
+
* on path/hash mismatch — closing the plant-between-dispatch-and-resume swap for a
|
|
434
|
+
* script in a worker-writable discovery dir. Purely additive/optional: absent on
|
|
435
|
+
* every manifest written before this field existed (and on dispatchers that do not
|
|
436
|
+
* stamp it yet) → no pin verification. */
|
|
437
|
+
dynamicScriptPin?: { scriptPath: string; sha256: string };
|
|
429
438
|
/** round-14 P1-5: typed workflow arguments accessible in .dwf.ts scripts via ctx.args<T>(). Any JSON value; default {} when unset. */
|
|
430
439
|
args?: unknown;
|
|
431
440
|
/** Per-run token budget snapshot. budgetTotal is the cap; budgetWarning/budgetAbort are fractions in [0,1] at which the team-runner emits run.budget_warning / run.budget_abort. budgetUnlimited=true opts out of enforcement. Optional for backward compat. */
|
|
@@ -27,6 +27,7 @@ import * as path from "node:path";
|
|
|
27
27
|
|
|
28
28
|
import { readCrewAgentEventsCursor } from "../../runtime/crew-agent-records.ts";
|
|
29
29
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
30
|
+
import { stripTerminalSequences } from "../../utils/safe-terminal-text.ts";
|
|
30
31
|
|
|
31
32
|
const MAX_TRANSCRIPT_ITEMS = 500;
|
|
32
33
|
|
|
@@ -127,21 +128,148 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
127
128
|
* (role:"toolResult" message_end).
|
|
128
129
|
*/
|
|
129
130
|
function normalizeResultContent(raw: unknown): Array<{ type: string; text?: string }> {
|
|
130
|
-
|
|
131
|
+
// w7a-render: every string on this path is worker-written and handed to pi
|
|
132
|
+
// host components (ToolExecutionComponent) — strip terminal escape
|
|
133
|
+
// sequences (OSC 52/OSC 8/CSI/APC + residual introducers) at the boundary.
|
|
134
|
+
if (typeof raw === "string") return [{ type: "text", text: stripTerminalSequences(raw) }];
|
|
131
135
|
if (!Array.isArray(raw)) return [];
|
|
132
136
|
return raw.flatMap((part) => {
|
|
133
|
-
if (typeof part === "string") return [{ type: "text", text: part }];
|
|
137
|
+
if (typeof part === "string") return [{ type: "text", text: stripTerminalSequences(part) }];
|
|
134
138
|
const record = asRecord(part);
|
|
135
139
|
if (!record) return [];
|
|
136
140
|
return [
|
|
137
141
|
{
|
|
138
142
|
type: typeof record.type === "string" ? record.type : "text",
|
|
139
|
-
text: typeof record.text === "string" ? record.text : undefined,
|
|
143
|
+
text: typeof record.text === "string" ? stripTerminalSequences(record.text) : undefined,
|
|
140
144
|
},
|
|
141
145
|
];
|
|
142
146
|
});
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/**
|
|
150
|
+
* w7a-render: sanitize the printable string fields of a retained assistant
|
|
151
|
+
* message (text + thinking content parts) before the full message object is
|
|
152
|
+
* handed to pi's AssistantMessageComponent, which renders them verbatim.
|
|
153
|
+
* Returns the SAME object when nothing needed stripping (component-cache
|
|
154
|
+
* fingerprint stays stable). Identity fields (usage/role/stopReason) and
|
|
155
|
+
* non-string payloads pass through untouched.
|
|
156
|
+
*/
|
|
157
|
+
function sanitizeMessageForRender(message: Record<string, unknown>): Record<string, unknown> {
|
|
158
|
+
const content = message.content;
|
|
159
|
+
if (!Array.isArray(content)) return message;
|
|
160
|
+
let touched = false;
|
|
161
|
+
const parts = content.map((part) => {
|
|
162
|
+
const record = asRecord(part);
|
|
163
|
+
if (!record) return part;
|
|
164
|
+
if (record.type === "text" && typeof record.text === "string") {
|
|
165
|
+
const clean = stripTerminalSequences(record.text);
|
|
166
|
+
if (clean !== record.text) {
|
|
167
|
+
touched = true;
|
|
168
|
+
return { ...record, text: clean };
|
|
169
|
+
}
|
|
170
|
+
return part;
|
|
171
|
+
}
|
|
172
|
+
if (record.type === "thinking") {
|
|
173
|
+
let partTouched = false;
|
|
174
|
+
const clean: Record<string, unknown> = { ...record };
|
|
175
|
+
if (typeof record.thinking === "string") {
|
|
176
|
+
clean.thinking = stripTerminalSequences(record.thinking);
|
|
177
|
+
partTouched = clean.thinking !== record.thinking;
|
|
178
|
+
}
|
|
179
|
+
if (typeof record.text === "string") {
|
|
180
|
+
clean.text = stripTerminalSequences(record.text);
|
|
181
|
+
partTouched ||= clean.text !== record.text;
|
|
182
|
+
}
|
|
183
|
+
if (partTouched) {
|
|
184
|
+
touched = true;
|
|
185
|
+
return clean;
|
|
186
|
+
}
|
|
187
|
+
return part;
|
|
188
|
+
}
|
|
189
|
+
return part;
|
|
190
|
+
});
|
|
191
|
+
return touched ? { ...message, content: parts } : message;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* W9-R1 (w7a-render deferred residual): `tool_execution_start` event.args are
|
|
196
|
+
* worker-controlled and reach pi's ToolExecutionComponent verbatim — it
|
|
197
|
+
* interpolates top-level keys and string values RAW (formatToolCallWithArgs)
|
|
198
|
+
* and hands the whole record to per-tool call renderers. Strip terminal
|
|
199
|
+
* sequences from every string leaf (and every object key), recursing into
|
|
200
|
+
* nested objects/arrays down to ARGS_SANITIZE_MAX_DEPTH. Non-string leaves
|
|
201
|
+
* (numbers/booleans/null) are inert — they only ever render through
|
|
202
|
+
* JSON.stringify, whose output escapes control characters — and values below
|
|
203
|
+
* the depth cap likewise only surface JSON-escaped, so both are passed
|
|
204
|
+
* through untouched (nothing is sanitized that is not rendered raw).
|
|
205
|
+
* Returns the SAME object when nothing needed stripping (component-cache
|
|
206
|
+
* fingerprint stays stable, matching sanitizeMessageForRender).
|
|
207
|
+
*/
|
|
208
|
+
const ARGS_SANITIZE_MAX_DEPTH = 6;
|
|
209
|
+
|
|
210
|
+
function sanitizeArgsForRender(
|
|
211
|
+
args: Record<string, unknown>,
|
|
212
|
+
depth = ARGS_SANITIZE_MAX_DEPTH,
|
|
213
|
+
): { value: Record<string, unknown>; touched: boolean } {
|
|
214
|
+
// Null prototype: event args are unvalidated worker JSON — a "__proto__"
|
|
215
|
+
// key must never reach a prototype setter on the rebuilt record (same
|
|
216
|
+
// reasoning as config-validation's asRecord copy).
|
|
217
|
+
const clean: Record<string, unknown> = Object.create(null);
|
|
218
|
+
let touched = false;
|
|
219
|
+
for (const [rawKey, rawValue] of Object.entries(args)) {
|
|
220
|
+
const key = stripTerminalSequences(rawKey);
|
|
221
|
+
if (key !== rawKey) touched = true;
|
|
222
|
+
if (typeof rawValue === "string") {
|
|
223
|
+
const value = stripTerminalSequences(rawValue);
|
|
224
|
+
if (value !== rawValue) touched = true;
|
|
225
|
+
clean[key] = value;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const record = asRecord(rawValue);
|
|
229
|
+
if (depth > 0 && record) {
|
|
230
|
+
const nested = sanitizeArgsForRender(record, depth - 1);
|
|
231
|
+
if (nested.touched) touched = true;
|
|
232
|
+
clean[key] = nested.value;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (depth > 0 && Array.isArray(rawValue)) {
|
|
236
|
+
const nested = sanitizeArgsArrayForRender(rawValue, depth - 1);
|
|
237
|
+
if (nested.touched) touched = true;
|
|
238
|
+
clean[key] = nested.value;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
clean[key] = rawValue;
|
|
242
|
+
}
|
|
243
|
+
return touched ? { value: clean, touched: true } : { value: args, touched: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sanitizeArgsArrayForRender(items: unknown[], depth: number): { value: unknown[]; touched: boolean } {
|
|
247
|
+
let touched = false;
|
|
248
|
+
const clean = items.map((entry) => {
|
|
249
|
+
if (typeof entry === "string") {
|
|
250
|
+
const value = stripTerminalSequences(entry);
|
|
251
|
+
if (value !== entry) {
|
|
252
|
+
touched = true;
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
return entry;
|
|
256
|
+
}
|
|
257
|
+
const record = asRecord(entry);
|
|
258
|
+
if (depth > 0 && record) {
|
|
259
|
+
const nested = sanitizeArgsForRender(record, depth - 1);
|
|
260
|
+
if (nested.touched) touched = true;
|
|
261
|
+
return nested.value;
|
|
262
|
+
}
|
|
263
|
+
if (depth > 0 && Array.isArray(entry)) {
|
|
264
|
+
const nested = sanitizeArgsArrayForRender(entry, depth - 1);
|
|
265
|
+
if (nested.touched) touched = true;
|
|
266
|
+
return nested.value;
|
|
267
|
+
}
|
|
268
|
+
return entry;
|
|
269
|
+
});
|
|
270
|
+
return touched ? { value: clean, touched: true } : { value: items, touched: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
145
273
|
function textFromContent(content: unknown): string {
|
|
146
274
|
if (!Array.isArray(content)) return "";
|
|
147
275
|
return content
|
|
@@ -178,8 +306,12 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
178
306
|
const items: CrewTranscriptItem[] = [];
|
|
179
307
|
|
|
180
308
|
if (type === "tool_execution_start") {
|
|
181
|
-
|
|
182
|
-
const
|
|
309
|
+
// w7a-render: toolName is interpolated into ToolExecutionComponent's title
|
|
310
|
+
const name = stripTerminalSequences(typeof event.toolName === "string" ? event.toolName : "tool");
|
|
311
|
+
// w9-r1 (w7a deferred): args reach ToolExecutionComponent verbatim —
|
|
312
|
+
// keys and string values are interpolated raw, nested structures go to
|
|
313
|
+
// call renderers. Strip terminal sequences at the parser boundary.
|
|
314
|
+
const args = sanitizeArgsForRender((event.args as Record<string, unknown>) ?? {}).value;
|
|
183
315
|
const id = `${name}#${seq}`;
|
|
184
316
|
const item: PendingTool = { type: "tool", name, toolCallId: id, args, seq };
|
|
185
317
|
pending.set(id, item);
|
|
@@ -223,7 +355,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
223
355
|
|
|
224
356
|
if (message.role === "assistant") {
|
|
225
357
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
226
|
-
const text = textFromContent(content);
|
|
358
|
+
const text = stripTerminalSequences(textFromContent(content));
|
|
227
359
|
if (text) {
|
|
228
360
|
// Compaction can carry usage at the RECORD level (usage-only
|
|
229
361
|
// tail) instead of inside the message — merge it in so the
|
|
@@ -234,7 +366,13 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
234
366
|
if (recordUsage) {
|
|
235
367
|
merged = { ...message, usage: messageUsage ? { ...messageUsage, ...recordUsage } : recordUsage };
|
|
236
368
|
}
|
|
237
|
-
items.push({
|
|
369
|
+
items.push({
|
|
370
|
+
type: "assistant",
|
|
371
|
+
text,
|
|
372
|
+
seq,
|
|
373
|
+
message: sanitizeMessageForRender(merged),
|
|
374
|
+
usage: normalizeUsage(merged.usage),
|
|
375
|
+
});
|
|
238
376
|
}
|
|
239
377
|
// toolResult parts carry name + content; fold them into pending starts
|
|
240
378
|
// (normalized to the same updateResult envelope as the pi ≥0.84
|
|
@@ -242,7 +380,9 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
242
380
|
for (const part of content) {
|
|
243
381
|
const item = asRecord(part);
|
|
244
382
|
if (item?.type !== "toolResult") continue;
|
|
245
|
-
|
|
383
|
+
// Sanitize the fold key EXACTLY like tool_execution_start names so a
|
|
384
|
+
// payload-carrying toolName still pairs with its result.
|
|
385
|
+
const name = stripTerminalSequences(typeof item.name === "string" ? item.name : "tool");
|
|
246
386
|
const isError = item.isError === true;
|
|
247
387
|
matchPending(
|
|
248
388
|
pending,
|
|
@@ -258,7 +398,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
258
398
|
}
|
|
259
399
|
|
|
260
400
|
if (message.role === "user") {
|
|
261
|
-
const text = textFromContent(message.content);
|
|
401
|
+
const text = stripTerminalSequences(textFromContent(message.content));
|
|
262
402
|
if (text) items.push({ type: "user", text, seq });
|
|
263
403
|
return items;
|
|
264
404
|
}
|
|
@@ -266,7 +406,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
|
|
|
266
406
|
|
|
267
407
|
// System / other events become a dim system line.
|
|
268
408
|
if (type && type !== "message_update") {
|
|
269
|
-
const text = typeof event.text === "string" ? event.text : "";
|
|
409
|
+
const text = stripTerminalSequences(typeof event.text === "string" ? event.text : "");
|
|
270
410
|
if (text) items.push({ type: "system", text, seq });
|
|
271
411
|
}
|
|
272
412
|
|
|
@@ -314,7 +454,7 @@ export function readAgentTranscript(manifest: TeamRunManifest, taskId: string):
|
|
|
314
454
|
const prompt = readWorkerPrompt(manifest, taskId);
|
|
315
455
|
if (prompt !== undefined) {
|
|
316
456
|
promptSeeded.add(key);
|
|
317
|
-
buffer.unshift({ type: "user", text: prompt, seq: 0 });
|
|
457
|
+
buffer.unshift({ type: "user", text: stripTerminalSequences(prompt), seq: 0 });
|
|
318
458
|
}
|
|
319
459
|
}
|
|
320
460
|
if (buffer.length > MAX_TRANSCRIPT_ITEMS) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import * as piTui from "@earendil-works/pi-tui";
|
|
8
8
|
import { PiTeamsRuntimeConfigSchema, PiTeamsUiConfigSchema } from "../schema/config-schema.ts";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
|
|
9
|
+
import { sanitizeUntrustedLine, truncateToWidth, visibleWidth } from "../utils/visual.ts";
|
|
10
10
|
import { keyOf, matchesKey } from "./key-utils.ts";
|
|
11
11
|
import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
|
|
12
12
|
|
|
@@ -308,9 +308,14 @@ const SETTINGS: SettingDef[] = [
|
|
|
308
308
|
label: "Pi UI Theme",
|
|
309
309
|
type: "action",
|
|
310
310
|
action: "piTheme",
|
|
311
|
-
|
|
311
|
+
// w7a-render: theme names (and the active-theme value read from
|
|
312
|
+
// ~/.pi/agent/settings.json) derive from worker-writable files — sanitize
|
|
313
|
+
// before they reach SelectSubmenu.render, which interpolates items verbatim.
|
|
314
|
+
values: discoverPiThemes().map((t) => sanitizeUntrustedLine(t.name)),
|
|
312
315
|
tab: "themes",
|
|
313
|
-
description:
|
|
316
|
+
description:
|
|
317
|
+
"Overall terminal theme. Switches live (no restart). Currently: " +
|
|
318
|
+
sanitizeUntrustedLine(getActivePiTheme() ?? "dark (default)"),
|
|
314
319
|
},
|
|
315
320
|
// Autonomous
|
|
316
321
|
{
|