pi-crew 0.11.1 → 0.11.4
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 +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { canTransitionRunStatus } from "../contracts.ts";
|
|
22
22
|
import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
|
|
23
23
|
import { appendEvent } from "../event-log/event-log.ts";
|
|
24
|
-
import type { TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
24
|
+
import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
25
25
|
import { CURRENT_SCHEMA_VERSION } from "../types.ts";
|
|
26
26
|
import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
27
27
|
import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
|
|
@@ -193,19 +193,37 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
|
|
|
193
193
|
const now = Date.now();
|
|
194
194
|
const cached = runStateRootCache.get(key);
|
|
195
195
|
if (cached && cached.expiresAt > now) return cached.root;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
// F-L1 (2026-09-22): listing (run-index scopedRunRoots) UNIONS the user and
|
|
197
|
+
// project run roots, but this resolution used scopeBaseRoot's single XOR
|
|
198
|
+
// pick — a run living under the OTHER root (e.g. created by another cwd or
|
|
199
|
+
// before the repo got a marker) listed fine yet FAILED every by-ID lookup:
|
|
200
|
+
// team status / prune / forget / scheduler provenance all returned "not
|
|
201
|
+
// found". Try the primary root first (hot path unchanged — one contained
|
|
202
|
+
// resolve + cache hit), then fall back to the other root. The no-repo case
|
|
203
|
+
// never falls back to a project path, mirroring scopedRunRoots' `if
|
|
204
|
+
// (projectRoot)` guard — resolution sees exactly what listing sees.
|
|
205
|
+
const candidates = useProjectState(cwd) ? [projectCrewRoot(cwd), userCrewRoot()] : [userCrewRoot()];
|
|
206
|
+
for (const root of candidates) {
|
|
207
|
+
const runsRoot = path.join(root, DEFAULT_PATHS.state.runsSubdir);
|
|
208
|
+
const scopedPath = resolveContainedRelativePath(runsRoot, runId, "runId");
|
|
209
|
+
// resolveRealContainedPath deliberately ACCEPTS a missing target (write-path
|
|
210
|
+
// semantics: ENOENT is fine, only symlink/escape violations throw) — so
|
|
211
|
+
// existence must be probed explicitly. Without this, the primary root
|
|
212
|
+
// always "wins" with a phantom path and the fallback can never run.
|
|
213
|
+
if (!fs.existsSync(path.join(scopedPath, DEFAULT_PATHS.state.manifestFile))) continue;
|
|
214
|
+
try {
|
|
215
|
+
resolveRealContainedPath(runsRoot, runId);
|
|
216
|
+
} catch {
|
|
217
|
+
continue; // symlink/escape violation under this root — try the other
|
|
218
|
+
}
|
|
219
|
+
if (runStateRootCache.size >= RUN_STATE_ROOT_CACHE_MAX) {
|
|
220
|
+
const oldest = runStateRootCache.keys().next().value;
|
|
221
|
+
if (oldest !== undefined) runStateRootCache.delete(oldest);
|
|
222
|
+
}
|
|
223
|
+
runStateRootCache.set(key, { root: scopedPath, expiresAt: now + RUN_STATE_ROOT_TTL_MS });
|
|
224
|
+
return scopedPath;
|
|
206
225
|
}
|
|
207
|
-
|
|
208
|
-
return scopedPath;
|
|
226
|
+
return undefined;
|
|
209
227
|
}
|
|
210
228
|
|
|
211
229
|
// PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
|
|
@@ -239,7 +257,13 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
239
257
|
manifest.eventsPath !== path.join(stateRoot, "events.jsonl")
|
|
240
258
|
)
|
|
241
259
|
return false;
|
|
242
|
-
|
|
260
|
+
// F-L1 (2026-09-22): derive the artifacts parent from the RESOLVED stateRoot's
|
|
261
|
+
// base root (…/<base>/state/runs/<runId>), not scopeBaseRoot — cross-root
|
|
262
|
+
// runs (state under the fallback root, artifacts written beside it at
|
|
263
|
+
// creation) must validate against their OWN root; scopeBaseRoot would
|
|
264
|
+
// reject every fallback-resolved run and keep it invisible by ID.
|
|
265
|
+
const baseRoot = path.resolve(stateRoot, "..", "..", "..");
|
|
266
|
+
const artifactsParent = path.join(baseRoot, DEFAULT_PATHS.state.artifactsSubdir);
|
|
243
267
|
const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
|
|
244
268
|
if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
|
|
245
269
|
// PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
|
|
@@ -409,9 +433,20 @@ export function createRunManifest(params: {
|
|
|
409
433
|
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
410
434
|
/** round-14 P1-5: typed workflow arguments for .dwf.ts scripts (ctx.args<T>()). */
|
|
411
435
|
args?: unknown;
|
|
436
|
+
/** Deterministic-capture support (2026-09-22): pin the run ID and/or clock
|
|
437
|
+
* so tools like docs/ui-samples/capture.ts produce byte-stable output.
|
|
438
|
+
* Defaults preserve current behavior (generated id, wall clock). */
|
|
439
|
+
runId?: string;
|
|
440
|
+
now?: () => Date;
|
|
441
|
+
/** Model routing snapshot (Finding #1, battery 2026-09-29): detached runs
|
|
442
|
+
* re-enter through background-runner with no ExtensionContext, so the
|
|
443
|
+
* caller's model override / inherited session model / auth-filtered
|
|
444
|
+
* catalogue must be persisted on the manifest at creation time.
|
|
445
|
+
* Omitted when undefined so old manifests stay byte-identical. */
|
|
446
|
+
modelContext?: RunModelContext;
|
|
412
447
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
413
|
-
const paths = createRunPaths(params.cwd);
|
|
414
|
-
const now = new Date().toISOString();
|
|
448
|
+
const paths = createRunPaths(params.cwd, params.runId);
|
|
449
|
+
const now = (params.now ? params.now() : new Date()).toISOString();
|
|
415
450
|
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
|
|
416
451
|
const manifest: TeamRunManifest = {
|
|
417
452
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
@@ -433,6 +468,7 @@ export function createRunManifest(params: {
|
|
|
433
468
|
...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
|
|
434
469
|
runKind: params.runKind ?? "team-run",
|
|
435
470
|
...(params.args !== undefined ? { args: params.args } : {}),
|
|
471
|
+
...(params.modelContext ? { modelContext: params.modelContext } : {}),
|
|
436
472
|
};
|
|
437
473
|
fs.mkdirSync(paths.stateRoot, { recursive: true });
|
|
438
474
|
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
|
|
@@ -479,7 +515,12 @@ export function createRunManifest(params: {
|
|
|
479
515
|
return { manifest, tasks, paths };
|
|
480
516
|
}
|
|
481
517
|
|
|
482
|
-
export function saveRunManifest(manifest: TeamRunManifest):
|
|
518
|
+
export function saveRunManifest(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): TeamRunManifest {
|
|
519
|
+
// Finding 8 (2026-09-23): terminal-preserve at the WRITE layer. Mid-flight
|
|
520
|
+
// savers (task-runner artifact/progress writes carrying a stale in-memory
|
|
521
|
+
// "running" manifest) previously overwrote an externally-written terminal
|
|
522
|
+
// status; merge/finalize had their own guards but every other save site did
|
|
523
|
+
// not. Guard here so NO raw save can erase a terminal disk status.
|
|
483
524
|
// FIX: Capture the cached tasks array + mtime/size BEFORE we invalidate the
|
|
484
525
|
// cache. The previous implementation re-read tasks.json from disk after the
|
|
485
526
|
// manifest write (a JSON.parse + fs.readFileSync per call), which defeated
|
|
@@ -502,12 +543,13 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
502
543
|
// which is always safe.
|
|
503
544
|
invalidateRunCache(manifest.stateRoot);
|
|
504
545
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
546
|
+
const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
|
|
505
547
|
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
|
|
506
548
|
// saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
|
|
507
549
|
// it, broker loadRunManifestById is a cross-process reader, and the
|
|
508
550
|
// statSync-based cache repopulation below needs the real post-write
|
|
509
551
|
// mtime/size). The 50ms coalesce window broke all three.
|
|
510
|
-
atomicWriteJson(manifestPath,
|
|
552
|
+
atomicWriteJson(manifestPath, effective);
|
|
511
553
|
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
512
554
|
// doesn't miss the cache on next read. Without this, every load until
|
|
513
555
|
// TTL expires would hit disk because cached 0 !== any real mtime.
|
|
@@ -516,16 +558,17 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
516
558
|
// fresh tasks should call saveRunTasks or loadRunTasks separately.
|
|
517
559
|
const manifestStat = fs.statSync(manifestPath);
|
|
518
560
|
setManifestCache(manifest.stateRoot, {
|
|
519
|
-
manifest,
|
|
561
|
+
manifest: effective,
|
|
520
562
|
tasks: cachedTasks,
|
|
521
563
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
522
564
|
manifestSize: manifestStat.size,
|
|
523
565
|
tasksMtimeMs: cachedTasksMtimeMs,
|
|
524
566
|
tasksSize: cachedTasksSize,
|
|
525
567
|
});
|
|
568
|
+
return effective;
|
|
526
569
|
}
|
|
527
570
|
|
|
528
|
-
export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<
|
|
571
|
+
export async function saveRunManifestAsync(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): Promise<TeamRunManifest> {
|
|
529
572
|
// FIX: Capture cached tasks array + mtime/size BEFORE invalidating, same
|
|
530
573
|
// rationale as the sync saveRunManifest above. The async path previously
|
|
531
574
|
// always set tasks: [] with mtime/size 0, so any cache hit was guaranteed
|
|
@@ -538,7 +581,8 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
|
|
|
538
581
|
// after a crash. See saveRunManifest for full explanation.
|
|
539
582
|
invalidateRunCache(manifest.stateRoot);
|
|
540
583
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
541
|
-
|
|
584
|
+
const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
|
|
585
|
+
await atomicWriteJsonAsync(manifestPath, effective);
|
|
542
586
|
// FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
|
|
543
587
|
// RACE GUARD: another concurrent async save (OPT-02) may unlink+rewrite
|
|
544
588
|
// manifest.json between our atomicWriteJsonAsync and this stat. If stat
|
|
@@ -553,13 +597,14 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
|
|
|
553
597
|
manifestStat = { mtimeMs: 0, size: 0 };
|
|
554
598
|
}
|
|
555
599
|
setManifestCache(manifest.stateRoot, {
|
|
556
|
-
manifest,
|
|
600
|
+
manifest: effective,
|
|
557
601
|
tasks: cachedTasks,
|
|
558
602
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
559
603
|
manifestSize: manifestStat.size,
|
|
560
604
|
tasksMtimeMs: cachedTasksMtimeMs,
|
|
561
605
|
tasksSize: cachedTasksSize,
|
|
562
606
|
});
|
|
607
|
+
return effective;
|
|
563
608
|
}
|
|
564
609
|
|
|
565
610
|
/**
|
|
@@ -803,6 +848,44 @@ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
803
848
|
export interface UpdateRunStatusOptions {
|
|
804
849
|
data?: Record<string, unknown>;
|
|
805
850
|
metadata?: Parameters<typeof appendEvent>[1]["metadata"];
|
|
851
|
+
/** Finding 8 (2026-09-23): allow leaving a TERMINAL disk status (resume is the
|
|
852
|
+
* only legitimate terminal-exit flow). Defaults to false — a raw save with an
|
|
853
|
+
* in-memory "running" manifest must never erase an externally-written
|
|
854
|
+
* cancelled/failed/completed status (live race team_20260923175042: cancel
|
|
855
|
+
* 17:51:08 → intermediate task-runner save re-wrote "running" → finalize
|
|
856
|
+
* completed — the cancel was fully erased). */
|
|
857
|
+
allowTerminalExit?: boolean;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Finding 8: statuses a disk write must never silently leave. Mirrors
|
|
861
|
+
* isRunTerminalPreserved (merge-loop.ts) — kept local to avoid a runtime dep
|
|
862
|
+
* from the store layer to the runtime layer. */
|
|
863
|
+
const DISK_TERMINAL_STATUSES: ReadonlySet<TeamRunManifest["status"]> = new Set(["cancelled", "failed", "completed"]);
|
|
864
|
+
|
|
865
|
+
/** Finding 8 write-layer guard: if the DISK manifest is terminal and the
|
|
866
|
+
* incoming write carries a NON-terminal status (the erase class — a mid-flight
|
|
867
|
+
* saver with a stale in-memory "running" manifest), preserve the disk
|
|
868
|
+
* status/summary/updatedAt while keeping every other incoming field (artifacts,
|
|
869
|
+
* usage, surface…). Terminal→terminal re-decisions (e.g. cancelling a run that
|
|
870
|
+
* just completed — pinned by resume-cancel.test.ts) are LEGITIMATE and pass
|
|
871
|
+
* through; they are governed by canTransitionRunStatus at the updateRunStatus
|
|
872
|
+
* layer. Returns the effective manifest to persist. */
|
|
873
|
+
function preserveDiskTerminalStatus(manifest: TeamRunManifest, allowTerminalExit: boolean | undefined): TeamRunManifest {
|
|
874
|
+
if (allowTerminalExit) return manifest;
|
|
875
|
+
// Terminal→terminal re-decisions pass through (governed by
|
|
876
|
+
// canTransitionRunStatus at the updateRunStatus layer); the guard applies
|
|
877
|
+
// ONLY to the erase class: a NON-terminal incoming status over terminal disk.
|
|
878
|
+
if (DISK_TERMINAL_STATUSES.has(manifest.status)) return manifest;
|
|
879
|
+
try {
|
|
880
|
+
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
881
|
+
// Raw read (no cache): cross-process cancel writes must be seen NOW.
|
|
882
|
+
const raw = fs.readFileSync(manifestPath, "utf-8");
|
|
883
|
+
const disk = JSON.parse(raw) as TeamRunManifest;
|
|
884
|
+
if (!DISK_TERMINAL_STATUSES.has(disk.status) || disk.status === manifest.status) return manifest;
|
|
885
|
+
return { ...manifest, status: disk.status, summary: disk.summary, updatedAt: disk.updatedAt };
|
|
886
|
+
} catch {
|
|
887
|
+
return manifest; // no disk manifest yet (create) or unreadable — normal write
|
|
888
|
+
}
|
|
806
889
|
}
|
|
807
890
|
|
|
808
891
|
export function updateRunStatus(
|
|
@@ -820,7 +903,21 @@ export function updateRunStatus(
|
|
|
820
903
|
updatedAt: new Date().toISOString(),
|
|
821
904
|
summary: summary ?? manifest.summary,
|
|
822
905
|
};
|
|
823
|
-
|
|
906
|
+
// Finding 8 (2026-09-23): the write-layer guard may PRESERVE a terminal disk
|
|
907
|
+
// status (an external cancel/reconciler write the in-memory manifest never
|
|
908
|
+
// saw). In that case do NOT emit run.<status>, do NOT flip the manifest —
|
|
909
|
+
// record the refusal and return the preserved state. Resume passes
|
|
910
|
+
// allowTerminalExit for its legitimate cancelled→running transition.
|
|
911
|
+
const saved = saveRunManifest(updated, { allowTerminalExit: options.allowTerminalExit });
|
|
912
|
+
if (saved.status !== status) {
|
|
913
|
+
appendEvent(saved.eventsPath, {
|
|
914
|
+
type: "run.terminal_preserved",
|
|
915
|
+
runId: saved.runId,
|
|
916
|
+
message: `Preserved terminal status '${saved.status}'; refused in-memory transition to '${status}'.`,
|
|
917
|
+
data: { preserved: saved.status, refused: status },
|
|
918
|
+
});
|
|
919
|
+
return saved;
|
|
920
|
+
}
|
|
824
921
|
// Unregister from active-run-index when run reaches a terminal status.
|
|
825
922
|
// Without this, stale entries accumulate (e.g. integration tests in /tmp) and
|
|
826
923
|
// Pi UI shows ghost "queued" runs that are actually completed/failed/cancelled.
|
package/src/state/types.ts
CHANGED
|
@@ -543,6 +543,10 @@ export interface GoalLoopState {
|
|
|
543
543
|
nextTurnFeedback?: string;
|
|
544
544
|
/** The team-run of the current in-flight turn (for cancel/steer). */
|
|
545
545
|
currentRunId?: string;
|
|
546
|
+
/** GL-1b (2026-09-22): why the last turn (or the loop itself) failed —
|
|
547
|
+
* persisted at goal level because turn-run dirs are pruned (keep=10 at
|
|
548
|
+
* session start) and otherwise the reason becomes unrecoverable. */
|
|
549
|
+
lastTurnError?: string;
|
|
546
550
|
verdicts: GoalVerdict[];
|
|
547
551
|
history: {
|
|
548
552
|
runId: string;
|
|
@@ -116,13 +116,16 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
116
116
|
// One pinned clock (D6-T4) for the whole pane render.
|
|
117
117
|
const nowMs = options.nowMs ?? Date.now();
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
119
|
+
// RR-021 WI-4.3c: first-wins Map keyed by taskId — the two `.find` sites
|
|
120
|
+
// below (real-agent filter + per-agent handle) must keep seeing the SAME
|
|
121
|
+
// (first) matching handle, so the build guards with has() instead of set()
|
|
122
|
+
// overwriting later duplicates.
|
|
123
|
+
const liveByTaskId = new Map<string, (typeof liveForRun)[number]>();
|
|
124
|
+
for (const handle of liveForRun) {
|
|
125
|
+
if (!liveByTaskId.has(handle.taskId)) liveByTaskId.set(handle.taskId, handle);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const realAgents = snapshot.agents.filter((a) => isRealAgent(a, liveByTaskId.get(a.taskId), nowMs));
|
|
126
129
|
const lineCount = Math.min(realAgents.length, 12);
|
|
127
130
|
const label =
|
|
128
131
|
realAgents.length !== snapshot.agents.length
|
|
@@ -135,7 +138,7 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
135
138
|
lines.push(`${completed}/${total} tasks · ${label}`);
|
|
136
139
|
|
|
137
140
|
for (const agent of realAgents.slice(0, 12)) {
|
|
138
|
-
const liveHandle =
|
|
141
|
+
const liveHandle = liveByTaskId.get(agent.taskId);
|
|
139
142
|
const icon = agentGlyph(agent.status, agent.taskId);
|
|
140
143
|
const role = `${agent.role ?? "?"}`;
|
|
141
144
|
|
|
@@ -178,8 +181,17 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
178
181
|
stats.push(liveHandle.modelName);
|
|
179
182
|
}
|
|
180
183
|
} else if (agent.startedAt) {
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
// Tier 13 (2026-09-21): a FINISHED agent's span must end at completedAt,
|
|
185
|
+
// never at the wall clock. The naive `nowMs - startedAt` made every
|
|
186
|
+
// completed agent of an old run inflate forever — the dashboard showed
|
|
187
|
+
// `01_explore … 29m39s` while the tool card showed the real `8m22s` for
|
|
188
|
+
// the same agent. Mirror computeLiveDurationMs: prefer a sane completedAt,
|
|
189
|
+
// else fall back to now; never emit a negative or absurd span.
|
|
190
|
+
const startedMs = new Date(agent.startedAt).getTime();
|
|
191
|
+
const rawCompleted = agent.completedAt ? new Date(agent.completedAt).getTime() : Number.NaN;
|
|
192
|
+
const completedMs = Number.isFinite(rawCompleted) && rawCompleted >= startedMs && rawCompleted <= nowMs ? rawCompleted : nowMs;
|
|
193
|
+
const ms = completedMs - startedMs;
|
|
194
|
+
if (Number.isFinite(ms) && ms >= 0) stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH));
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
|
|
@@ -32,6 +32,32 @@ function isActiveTask(task: TeamTaskState): boolean {
|
|
|
32
32
|
return task.status === "running";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* FINDING 5 (2026-09-23 battery): health ticks must count task statuses from
|
|
37
|
+
* DISK truth, not the (possibly lagging) snapshot cache. A worker parked on
|
|
38
|
+
* `ask` transitions running → waiting on disk while the cached snapshot can
|
|
39
|
+
* still show `running` — in that window the parked worker counted as
|
|
40
|
+
* active-without-heartbeat and fired a false "dead worker" notification (live:
|
|
41
|
+
* run team_20260923100114, 01_explore parked on ask, later answered+resumed).
|
|
42
|
+
*
|
|
43
|
+
* Overlay FRESH task statuses (and heartbeats) from disk onto the cached
|
|
44
|
+
* snapshot before summarizing. Returns the ORIGINAL snapshot object when
|
|
45
|
+
* nothing diverged (cheap identity check — callers use it to decide cache
|
|
46
|
+
* invalidation), or a shallow copy with the diverging tasks replaced.
|
|
47
|
+
*/
|
|
48
|
+
export function overlayFreshTaskStatuses(snapshot: RunUiSnapshot, freshTasks: TeamTaskState[]): RunUiSnapshot {
|
|
49
|
+
if (freshTasks.length === 0) return snapshot;
|
|
50
|
+
const freshById = new Map(freshTasks.map((task) => [task.id, task]));
|
|
51
|
+
let diverged = false;
|
|
52
|
+
const tasks = snapshot.tasks.map((task) => {
|
|
53
|
+
const fresh = freshById.get(task.id);
|
|
54
|
+
if (!fresh || (fresh.status === task.status && fresh.heartbeat === task.heartbeat)) return task;
|
|
55
|
+
diverged = true;
|
|
56
|
+
return { ...task, status: fresh.status, heartbeat: fresh.heartbeat };
|
|
57
|
+
});
|
|
58
|
+
return diverged ? { ...snapshot, tasks } : snapshot;
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSummaryOptions = {}): HeartbeatSummary {
|
|
36
62
|
const staleMs = opts.staleMs ?? 60_000;
|
|
37
63
|
const deadMs = opts.deadMs ?? 5 * 60_000;
|
|
@@ -55,6 +81,14 @@ export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSumm
|
|
|
55
81
|
const runTerminal = isTerminalRunStatus(snapshot.manifest.status);
|
|
56
82
|
for (const task of snapshot.tasks) {
|
|
57
83
|
if (runTerminal || !isActiveTask(task)) continue;
|
|
84
|
+
// Guest-child tasks (delegate subagents, agent === "delegate") have no
|
|
85
|
+
// heartbeat channel — they never write task.heartbeat and complete in
|
|
86
|
+
// seconds; their lifecycle is owned by delegate.requested/admitted/
|
|
87
|
+
// completed broker events. Counting them here fired a false "N worker(s)
|
|
88
|
+
// missing heartbeat" ambient for every delegate-using run (live:
|
|
89
|
+
// team_20260926033657_2b6c6d2610b26d9d, 2026-09-26 battery — same
|
|
90
|
+
// gc-blindness root shape as the heartbeat-watcher fix).
|
|
91
|
+
if (task.agent === "delegate") continue;
|
|
58
92
|
const heartbeat = task.heartbeat;
|
|
59
93
|
if (!heartbeat) {
|
|
60
94
|
summary.missing += 1;
|
|
@@ -277,7 +277,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
|
|
|
277
277
|
// (lazy import: viewers.ts pulls the state-store chain and must stay
|
|
278
278
|
// off this module's startup path — AGENTS.md lazy boundary).
|
|
279
279
|
onOpenBrowser: () => {
|
|
280
|
-
void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx));
|
|
280
|
+
void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx)); // LAZY: defer agents-jobs browser until opened
|
|
281
281
|
},
|
|
282
282
|
});
|
|
283
283
|
});
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -33,6 +33,7 @@ import * as fs from "node:fs";
|
|
|
33
33
|
import * as path from "node:path";
|
|
34
34
|
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
35
35
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
36
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
36
37
|
import { keyOf } from "./key-utils.ts";
|
|
37
38
|
|
|
38
39
|
export const DASHBOARD_KEYS = {
|
|
@@ -41,6 +42,8 @@ export const DASHBOARD_KEYS = {
|
|
|
41
42
|
help: ["?"],
|
|
42
43
|
root: {
|
|
43
44
|
summary: ["u"],
|
|
45
|
+
/** US-020: cancel the selected run (2-step confirm in the dashboard). */
|
|
46
|
+
cancel: ["x"],
|
|
44
47
|
artifacts: ["a"],
|
|
45
48
|
api: ["i"],
|
|
46
49
|
agents: ["d"],
|
|
@@ -112,6 +115,7 @@ export type DashboardKeyAction =
|
|
|
112
115
|
| "live-conversation"
|
|
113
116
|
| "reload"
|
|
114
117
|
| "browser"
|
|
118
|
+
| "cancel"
|
|
115
119
|
| "pane-agents"
|
|
116
120
|
| "pane-progress"
|
|
117
121
|
| "pane-mailbox"
|
|
@@ -256,6 +260,9 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
|
|
|
256
260
|
// safe; inside the browser overlay itself "p" is free because overlays
|
|
257
261
|
// are mutually exclusive (see keybinding-map.ts header note).
|
|
258
262
|
{ keys: DASHBOARD_KEYS.root.browser, action: "browser" },
|
|
263
|
+
// US-020: x → cancel the selected run (unscoped; the schedules pane's X
|
|
264
|
+
// delete is pane-scoped uppercase — pass-1 exact match keeps them distinct).
|
|
265
|
+
{ keys: DASHBOARD_KEYS.root.cancel, action: "cancel" },
|
|
259
266
|
{ keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
|
|
260
267
|
{ keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
|
|
261
268
|
{ keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
|
|
@@ -418,7 +425,10 @@ export type KeybindingOverride = Partial<Record<DashboardKeyAction | OverlayBind
|
|
|
418
425
|
const KEYBINDINGS_ENV = "PI_CREW_KEYBINDINGS";
|
|
419
426
|
|
|
420
427
|
/** Every dispatched action + every `overlay:*` binding is a valid override target. */
|
|
421
|
-
const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([
|
|
428
|
+
export const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([
|
|
429
|
+
...DEFAULT_BINDINGS.map((b) => b.action),
|
|
430
|
+
...OVERLAY_BINDING_KEYS,
|
|
431
|
+
]);
|
|
422
432
|
|
|
423
433
|
/** Coerce an unknown parsed value into a safe {@link KeybindingOverride}. */
|
|
424
434
|
function parseKeybindingOverride(raw: unknown): KeybindingOverride {
|
|
@@ -541,10 +551,17 @@ function computeEffectiveBindings(overrides: KeybindingOverride): EffectiveBindi
|
|
|
541
551
|
return { bindings, overlayBindings, reverted: [...reverted] };
|
|
542
552
|
}
|
|
543
553
|
|
|
544
|
-
/**
|
|
554
|
+
/**
|
|
555
|
+
* Read the `keybindings` section from the project config.
|
|
556
|
+
*
|
|
557
|
+
* RR-020 Fix 4: resolve via `projectCrewRoot(cwd)` instead of a literal
|
|
558
|
+
* `<cwd>/.crew` — the config lives in whichever layout the project actually
|
|
559
|
+
* uses (`.crew/` or `.pi/teams/`), so a `.pi/teams` project's keybinding
|
|
560
|
+
* overrides are no longer silently ignored.
|
|
561
|
+
*/
|
|
545
562
|
function readConfigKeybindings(cwd: string): KeybindingOverride {
|
|
546
563
|
try {
|
|
547
|
-
const raw: unknown = JSON.parse(fs.readFileSync(path.join(cwd, "
|
|
564
|
+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(projectCrewRoot(cwd), "config.json"), "utf-8"));
|
|
548
565
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
549
566
|
return parseKeybindingOverride((raw as Record<string, unknown>).keybindings);
|
|
550
567
|
} catch {
|
|
@@ -565,7 +582,8 @@ function readEnvKeybindings(): KeybindingOverride {
|
|
|
565
582
|
|
|
566
583
|
function configKeybindingsMtime(cwd: string): number | undefined {
|
|
567
584
|
try {
|
|
568
|
-
|
|
585
|
+
// RR-020 Fix 4: same layout-resolved config path as readConfigKeybindings.
|
|
586
|
+
return fs.statSync(path.join(projectCrewRoot(cwd), "config.json")).mtimeMs;
|
|
569
587
|
} catch {
|
|
570
588
|
return undefined;
|
|
571
589
|
}
|
|
@@ -62,11 +62,14 @@ export class LiveConversationOverlay {
|
|
|
62
62
|
private handle: LiveAgentHandle;
|
|
63
63
|
private theme: CrewTheme;
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
private readonly nowMs: () => number;
|
|
66
|
+
|
|
67
|
+
constructor(handle: LiveAgentHandle, theme: CrewTheme, columns = 80, rows = 24, nowMs?: () => number) {
|
|
66
68
|
this.handle = handle;
|
|
67
69
|
this.theme = theme;
|
|
68
70
|
this.columns = columns;
|
|
69
71
|
this.rows = rows;
|
|
72
|
+
this.nowMs = nowMs ?? Date.now;
|
|
70
73
|
// R8: Subscribe to real session events if available
|
|
71
74
|
const session = handle.session as Record<string, unknown>;
|
|
72
75
|
if (typeof session.subscribe === "function") {
|
|
@@ -117,7 +120,7 @@ export class LiveConversationOverlay {
|
|
|
117
120
|
|
|
118
121
|
private refreshSummary(): void {
|
|
119
122
|
const act = this.handle.activity;
|
|
120
|
-
const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
|
|
123
|
+
const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s]`;
|
|
121
124
|
const lastLine = this.cachedLines[this.cachedLines.length - 1];
|
|
122
125
|
if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
|
|
123
126
|
this.cachedLines[this.cachedLines.length - 1] = summary;
|
|
@@ -267,7 +270,7 @@ export class LiveConversationOverlay {
|
|
|
267
270
|
if (act.maxTurns != null) parts.push(`turn ${act.turnCount ?? 0}/${act.maxTurns}`);
|
|
268
271
|
else if ((act.turnCount ?? 0) > 0) parts.push(`turn ${act.turnCount}`);
|
|
269
272
|
if ((act.toolUses ?? 0) > 0) parts.push(formatCount(act.toolUses ?? 0, "tool"));
|
|
270
|
-
parts.push(`${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`);
|
|
273
|
+
parts.push(`${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s`);
|
|
271
274
|
try {
|
|
272
275
|
const ctxPct = this.handle.session.getSessionStats?.()?.contextUsage?.percent;
|
|
273
276
|
if (ctxPct != null) {
|
package/src/ui/render-diff.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type { Change } from "diff";
|
|
2
3
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
3
4
|
import { asCrewTheme } from "./theme-adapter.ts";
|
|
4
5
|
|
|
6
|
+
// RR-021 WI-2.1: the `diff` package was eagerly imported module-scope with a
|
|
7
|
+
// wildcard (pulling the whole namespace into every UI load). Only diffWords is
|
|
8
|
+
// used at runtime — lazy sync accessor on first use (model: ui/syntax-highlight.ts).
|
|
9
|
+
// `Change` is a type-only import (erased at compile time, zero runtime cost).
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
type DiffModule = typeof import("diff");
|
|
12
|
+
let cachedDiff: DiffModule | undefined;
|
|
13
|
+
function diffModule(): DiffModule {
|
|
14
|
+
if (!cachedDiff) cachedDiff = require("diff") as DiffModule;
|
|
15
|
+
return cachedDiff;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
interface ParsedDiffLine {
|
|
6
19
|
prefix: string;
|
|
7
20
|
lineNum: string;
|
|
@@ -36,8 +49,8 @@ const WORD_DIFF_MIN_SIM = 0.15;
|
|
|
36
49
|
* characters relative to the longer of the two strings, using word-level
|
|
37
50
|
* diff to identify the common (unchanged) parts. Returns a value in [0, 1].
|
|
38
51
|
*/
|
|
39
|
-
function computeSimilarity(oldContent: string, newContent: string, wordDiff?:
|
|
40
|
-
const parts = wordDiff ??
|
|
52
|
+
function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Change[]): number {
|
|
53
|
+
const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
|
|
41
54
|
let commonChars = 0;
|
|
42
55
|
for (const part of parts) {
|
|
43
56
|
if (!part.removed && !part.added) {
|
|
@@ -53,9 +66,9 @@ function renderIntraLineDiff(
|
|
|
53
66
|
theme: CrewTheme,
|
|
54
67
|
oldContent: string,
|
|
55
68
|
newContent: string,
|
|
56
|
-
wordDiff?:
|
|
69
|
+
wordDiff?: Change[],
|
|
57
70
|
): { removedLine: string; addedLine: string } {
|
|
58
|
-
const parts = wordDiff ??
|
|
71
|
+
const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
|
|
59
72
|
let removedLine = "";
|
|
60
73
|
let addedLine = "";
|
|
61
74
|
let isFirstRemoved = true;
|
|
@@ -137,7 +150,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
|
|
|
137
150
|
const newContent = replaceTabs(addedLines[0]!.content);
|
|
138
151
|
// PERF (2026-08-24): diffWords is the expensive call and was run
|
|
139
152
|
// TWICE on identical inputs (similarity + render). One pass, shared.
|
|
140
|
-
const wordDiff =
|
|
153
|
+
const wordDiff = diffModule().diffWords(oldContent, newContent);
|
|
141
154
|
const similarity = computeSimilarity(oldContent, newContent, wordDiff);
|
|
142
155
|
if (similarity >= WORD_DIFF_MIN_SIM) {
|
|
143
156
|
const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);
|