sortie-dogs 0.5.16 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/core/worktree-commit-artifact.js +158 -1
- package/dist/plugin/index.d.ts +2 -1
- package/dist/plugin/index.js +56 -44
- package/dist/plugin/run-metrics.d.ts +40 -0
- package/dist/plugin/run-metrics.js +169 -0
- package/dist/runtime-assets.d.ts +7 -7
- package/dist/runtime-assets.js +14 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
|
|
|
20
20
|
|
|
21
21
|
Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
|
|
22
22
|
|
|
23
|
-
Release: [v0.
|
|
23
|
+
Release: [v0.6.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.6.0)
|
|
24
24
|
|
|
25
25
|
## Quick start
|
|
26
26
|
|
package/dist/asset-version.d.ts
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export declare const RUNTIME_ASSET_VERSION = "0.3.
|
|
5
|
+
export declare const RUNTIME_ASSET_VERSION = "0.3.49-run-metrics-v1";
|
|
6
6
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export const RUNTIME_ASSET_VERSION = "0.3.
|
|
5
|
+
export const RUNTIME_ASSET_VERSION = "0.3.49-run-metrics-v1";
|
|
@@ -13,6 +13,7 @@ const MAX_OUTPUT = 1024 * 1024;
|
|
|
13
13
|
const MAX_TIMEOUT = 10 * 60_000;
|
|
14
14
|
const GIT_TIMEOUT = 30_000;
|
|
15
15
|
const EXIT_GRACE = 500;
|
|
16
|
+
let systemdUnitSequence = 0;
|
|
16
17
|
const KILL_WAIT = 2_000;
|
|
17
18
|
const WINDOWS_WRAPPER_GRACE = 15_000;
|
|
18
19
|
const LINUX_WRAPPER_GRACE = 10_000;
|
|
@@ -483,9 +484,163 @@ async function terminateTree(child, closed) {
|
|
|
483
484
|
return false;
|
|
484
485
|
};
|
|
485
486
|
if (!(await groupGone())) {
|
|
486
|
-
|
|
487
|
+
try {
|
|
488
|
+
process.kill(-child.pid, "SIGKILL");
|
|
489
|
+
}
|
|
490
|
+
catch { /* Already closed. */ }
|
|
491
|
+
if (!(await groupGone())) {
|
|
492
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Process-tree termination could not be confirmed.");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function linuxSystemdRun() {
|
|
498
|
+
for (const candidate of ["/usr/bin/systemd-run", "/bin/systemd-run"]) {
|
|
499
|
+
const canonical = await realpath(candidate).catch(() => undefined);
|
|
500
|
+
if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
|
|
501
|
+
continue;
|
|
502
|
+
const info = await lstat(canonical).catch(() => undefined);
|
|
503
|
+
if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
|
|
504
|
+
return canonical;
|
|
505
|
+
}
|
|
506
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
|
|
507
|
+
}
|
|
508
|
+
async function linuxSystemctl() {
|
|
509
|
+
for (const candidate of ["/usr/bin/systemctl", "/bin/systemctl"]) {
|
|
510
|
+
const canonical = await realpath(candidate).catch(() => undefined);
|
|
511
|
+
if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
|
|
512
|
+
continue;
|
|
513
|
+
const info = await lstat(canonical).catch(() => undefined);
|
|
514
|
+
if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
|
|
515
|
+
return canonical;
|
|
516
|
+
}
|
|
517
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
|
|
518
|
+
}
|
|
519
|
+
async function linuxEnvironmentExecutable() {
|
|
520
|
+
for (const candidate of ["/usr/bin/env", "/bin/env"]) {
|
|
521
|
+
const canonical = await realpath(candidate).catch(() => undefined);
|
|
522
|
+
if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
|
|
523
|
+
continue;
|
|
524
|
+
const info = await lstat(canonical).catch(() => undefined);
|
|
525
|
+
if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
|
|
526
|
+
return canonical;
|
|
527
|
+
}
|
|
528
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
|
|
529
|
+
}
|
|
530
|
+
async function linuxShell() {
|
|
531
|
+
for (const candidate of ["/bin/sh", "/usr/bin/sh"]) {
|
|
532
|
+
const canonical = await realpath(candidate).catch(() => undefined);
|
|
533
|
+
if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
|
|
534
|
+
continue;
|
|
535
|
+
const info = await lstat(canonical).catch(() => undefined);
|
|
536
|
+
if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
|
|
537
|
+
return canonical;
|
|
538
|
+
}
|
|
539
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
|
|
540
|
+
}
|
|
541
|
+
async function stopSystemdUnit(unit, environment) {
|
|
542
|
+
const systemctl = await linuxSystemctl();
|
|
543
|
+
const invoke = async (args) => {
|
|
544
|
+
const child = spawn(systemctl, args, { env: environment, shell: false, windowsHide: true, stdio: "ignore" });
|
|
545
|
+
const closed = new Promise((done, reject) => {
|
|
546
|
+
child.once("error", reject);
|
|
547
|
+
child.once("close", (code) => done({ code }));
|
|
548
|
+
});
|
|
549
|
+
if (!(await waitForClose(closed, KILL_WAIT + 1_500))) {
|
|
550
|
+
child.kill("SIGKILL");
|
|
551
|
+
if (!(await waitForClose(closed, KILL_WAIT))) {
|
|
552
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
|
|
553
|
+
}
|
|
487
554
|
}
|
|
555
|
+
return (await closed).code;
|
|
556
|
+
};
|
|
557
|
+
await invoke(["--user", "stop", unit]).catch(() => undefined);
|
|
558
|
+
const activeState = async () => {
|
|
559
|
+
const code = await invoke(["--user", "is-active", "--quiet", unit]);
|
|
560
|
+
if (code === 0)
|
|
561
|
+
return true;
|
|
562
|
+
if (code === 3 || code === 4)
|
|
563
|
+
return false;
|
|
564
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
|
|
565
|
+
};
|
|
566
|
+
const deadline = Date.now() + KILL_WAIT;
|
|
567
|
+
while (Date.now() < deadline) {
|
|
568
|
+
if (await activeState()) {
|
|
569
|
+
await invoke(["--user", "stop", unit]).catch(() => undefined);
|
|
570
|
+
}
|
|
571
|
+
await new Promise((done) => setTimeout(done, 25));
|
|
572
|
+
}
|
|
573
|
+
if (await activeState()) {
|
|
574
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function runLinuxSystemd(executable, args, cwd, timeout) {
|
|
578
|
+
const systemdRun = await linuxSystemdRun();
|
|
579
|
+
const [environmentExecutable, shell] = await Promise.all([linuxEnvironmentExecutable(), linuxShell()]);
|
|
580
|
+
const uid = process.getuid?.();
|
|
581
|
+
if (uid === undefined)
|
|
582
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
|
|
583
|
+
const runtimeDirectory = `/run/user/${uid}`;
|
|
584
|
+
const environment = {
|
|
585
|
+
...cleanEnvironment(),
|
|
586
|
+
XDG_RUNTIME_DIR: runtimeDirectory,
|
|
587
|
+
DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDirectory}/bus`,
|
|
588
|
+
};
|
|
589
|
+
systemdUnitSequence = (systemdUnitSequence + 1) % Number.MAX_SAFE_INTEGER;
|
|
590
|
+
const unit = `sortie-dogs-${process.pid}-${Date.now()}-${systemdUnitSequence}`;
|
|
591
|
+
const validationEnvironment = Object.entries(cleanEnvironment()).map(([key, value]) => `${key}=${value}`);
|
|
592
|
+
const child = spawn(systemdRun, [
|
|
593
|
+
"--user", "--wait", "--collect", "--quiet", `--unit=${unit}`,
|
|
594
|
+
"--property=KillMode=control-group", `--property=RuntimeMaxSec=${timeout}ms`,
|
|
595
|
+
"--property=TimeoutStopSec=1s", "--working-directory", cwd, "--",
|
|
596
|
+
environmentExecutable, "-i", ...validationEnvironment, shell, "-c",
|
|
597
|
+
'"$@"; code=$?; if [ "$code" -eq 0 ]; then exit 0; else exit 239; fi',
|
|
598
|
+
"sortie-validation", executable, ...args,
|
|
599
|
+
], {
|
|
600
|
+
cwd, env: environment, shell: false, windowsHide: true, detached: true, stdio: ["ignore", "pipe", "pipe"],
|
|
601
|
+
});
|
|
602
|
+
const chunks = [];
|
|
603
|
+
let outputBytes = 0;
|
|
604
|
+
let overflow = false;
|
|
605
|
+
const collect = (chunk) => {
|
|
606
|
+
outputBytes += chunk.byteLength;
|
|
607
|
+
if (outputBytes <= MAX_OUTPUT)
|
|
608
|
+
chunks.push(chunk);
|
|
609
|
+
else
|
|
610
|
+
overflow = true;
|
|
611
|
+
};
|
|
612
|
+
child.stdout.on("data", collect);
|
|
613
|
+
child.stderr.on("data", (chunk) => {
|
|
614
|
+
outputBytes += chunk.byteLength;
|
|
615
|
+
if (outputBytes > MAX_OUTPUT)
|
|
616
|
+
overflow = true;
|
|
617
|
+
});
|
|
618
|
+
const closed = new Promise((done, reject) => {
|
|
619
|
+
child.once("error", reject);
|
|
620
|
+
child.once("close", (code) => done({ code }));
|
|
621
|
+
});
|
|
622
|
+
let timer;
|
|
623
|
+
const bounded = await Promise.race([
|
|
624
|
+
closed.then((result) => ({ kind: "closed", result })),
|
|
625
|
+
new Promise((done) => {
|
|
626
|
+
timer = setTimeout(() => done({ kind: "timeout" }), timeout + LINUX_WRAPPER_GRACE);
|
|
627
|
+
}),
|
|
628
|
+
]).catch(async () => {
|
|
629
|
+
await terminateTree(child, closed).catch(() => undefined);
|
|
630
|
+
await stopSystemdUnit(unit, environment);
|
|
631
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation executable failed.");
|
|
632
|
+
});
|
|
633
|
+
if (timer !== undefined)
|
|
634
|
+
clearTimeout(timer);
|
|
635
|
+
if (bounded.kind === "timeout" || overflow) {
|
|
636
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
637
|
+
await terminateTree(child, closed);
|
|
638
|
+
await stopSystemdUnit(unit, environment);
|
|
639
|
+
throw new WorktreeCommitArtifactError("validation-failed", "Validation exceeded its resource bound.");
|
|
488
640
|
}
|
|
641
|
+
await stopSystemdUnit(unit, environment);
|
|
642
|
+
const code = bounded.result.code === 0 ? 0 : bounded.result.code === 1 ? 238 : 239;
|
|
643
|
+
return { code, stdout: Buffer.concat(chunks) };
|
|
489
644
|
}
|
|
490
645
|
async function runBounded(executable, args, cwd, timeout, kind) {
|
|
491
646
|
const windowsWrapper = kind === "validation" && process.platform === "win32";
|
|
@@ -557,6 +712,8 @@ async function runBounded(executable, args, cwd, timeout, kind) {
|
|
|
557
712
|
}
|
|
558
713
|
const code = linuxWrapper && result.code !== 0 && ![238, 239, 240, 241].includes(result.code ?? -1)
|
|
559
714
|
? 240 : result.code ?? -1;
|
|
715
|
+
if (linuxWrapper && code === 240)
|
|
716
|
+
return await runLinuxSystemd(executable, args, cwd, timeout);
|
|
560
717
|
return { code, stdout: Buffer.concat(chunks) };
|
|
561
718
|
}
|
|
562
719
|
catch (error) {
|
package/dist/plugin/index.d.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { type ContinuationClient } from "./continuation.js";
|
|
|
3
3
|
import { type ToolExecuteBeforeInput, type ToolExecuteBeforeOutput } from "./gate.js";
|
|
4
4
|
import { type OpenCodeChatMessageHook, type OpenCodeModelAvailabilityClient } from "./model-routing-hook.js";
|
|
5
5
|
import { type SessionMessageReader } from "./task-result-repair.js";
|
|
6
|
+
import type { RunMetricsClient } from "./run-metrics.js";
|
|
6
7
|
export declare const PARALLEL_COMMIT_ARTIFACT_CAPABILITY = "sortie_create_parallel_commit_artifact";
|
|
7
8
|
export interface OpenCodePluginInput {
|
|
8
9
|
directory: string;
|
|
9
10
|
worktree?: string;
|
|
10
11
|
/** The host SDK client. Absent in hosts that construct the plugin without one. */
|
|
11
|
-
client?: SessionMessageReader & ContinuationClient & OpenCodeModelAvailabilityClient & {
|
|
12
|
+
client?: SessionMessageReader & RunMetricsClient & ContinuationClient & OpenCodeModelAvailabilityClient & {
|
|
12
13
|
tui?: {
|
|
13
14
|
showToast?: (request: {
|
|
14
15
|
body: {
|
package/dist/plugin/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { BACKLOG_DRAIN_CAPABILITY, FastLaneController } from "./fast-lane.js";
|
|
|
18
18
|
import { createModelRoutingHook, } from "./model-routing-hook.js";
|
|
19
19
|
import { createTaskResultRepairHook, lastAssistantText, markConsultationFallbackRetry, taskChildSessionID, } from "./task-result-repair.js";
|
|
20
20
|
import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
|
|
21
|
+
import { collectRunMetrics, insertRunMetrics, isDoneTerminalText } from "./run-metrics.js";
|
|
21
22
|
const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
|
|
22
23
|
const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
23
24
|
const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
@@ -919,6 +920,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
919
920
|
return loaded?.gate === undefined && manifestAbsent;
|
|
920
921
|
}
|
|
921
922
|
const inspected = new Map();
|
|
923
|
+
const inspectionOperations = new Map();
|
|
922
924
|
const sessionAuthorizations = new Map();
|
|
923
925
|
const bindingPins = new Map();
|
|
924
926
|
const bindingOperations = new Set();
|
|
@@ -1383,8 +1385,8 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1383
1385
|
if (JSON.stringify(running.artifact.validation.command) !== JSON.stringify(requestedCommand)) {
|
|
1384
1386
|
return deny("artifact-replay");
|
|
1385
1387
|
}
|
|
1386
|
-
await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
|
|
1387
1388
|
await removeParallelControlFiles(binding.descriptor);
|
|
1389
|
+
await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
|
|
1388
1390
|
parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact: running.artifact });
|
|
1389
1391
|
pruneParallelChildMap(parallelArtifacts);
|
|
1390
1392
|
return JSON.stringify({ status: "created", replay: true, artifact: boundedParallelArtifact(running.artifact) });
|
|
@@ -1397,8 +1399,8 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1397
1399
|
};
|
|
1398
1400
|
const recovered = await recoverWorktreeCommitArtifact(produceRequest);
|
|
1399
1401
|
const artifact = recovered ?? await produceWorktreeCommitArtifact(produceRequest);
|
|
1400
|
-
await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
|
|
1401
1402
|
await removeParallelControlFiles(binding.descriptor);
|
|
1403
|
+
await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
|
|
1402
1404
|
parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact });
|
|
1403
1405
|
pruneParallelChildMap(parallelArtifacts);
|
|
1404
1406
|
return JSON.stringify({ status: "created", ...(recovered === undefined ? {} : { replay: true }),
|
|
@@ -1953,6 +1955,11 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1953
1955
|
return deny("binding-replay");
|
|
1954
1956
|
}
|
|
1955
1957
|
const existingAuthorization = sessionAuthorizations.get(sessionID);
|
|
1958
|
+
const pendingInspections = [...inspectionOperations.entries()]
|
|
1959
|
+
.filter(([key]) => key.startsWith(`${sessionID}\u0000`))
|
|
1960
|
+
.map(([, operation]) => operation);
|
|
1961
|
+
if (pendingInspections.length > 0)
|
|
1962
|
+
await Promise.allSettled(pendingInspections);
|
|
1956
1963
|
pruneInspections(now);
|
|
1957
1964
|
const inspectedEntry = [...inspected.entries()].find(([key, entry]) => key.startsWith(`${sessionID}\u0000`) &&
|
|
1958
1965
|
entry.ownerSessionID === sessionID &&
|
|
@@ -2401,7 +2408,17 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2401
2408
|
const path = input.args.filePath;
|
|
2402
2409
|
if (typeof path !== "string" || path.length === 0)
|
|
2403
2410
|
return;
|
|
2404
|
-
|
|
2411
|
+
const absolutePath = resolve(path);
|
|
2412
|
+
const key = `${input.sessionID}\u0000${absolutePath}`;
|
|
2413
|
+
const operation = inspect(path, input.sessionID).then(() => undefined);
|
|
2414
|
+
inspectionOperations.set(key, operation);
|
|
2415
|
+
try {
|
|
2416
|
+
await operation;
|
|
2417
|
+
}
|
|
2418
|
+
finally {
|
|
2419
|
+
if (inspectionOperations.get(key) === operation)
|
|
2420
|
+
inspectionOperations.delete(key);
|
|
2421
|
+
}
|
|
2405
2422
|
}
|
|
2406
2423
|
async function invalidateEditedHandoff(path) {
|
|
2407
2424
|
await ensureLoaded();
|
|
@@ -2482,7 +2499,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2482
2499
|
return undefined;
|
|
2483
2500
|
}
|
|
2484
2501
|
}
|
|
2485
|
-
async function
|
|
2502
|
+
async function hostSessionRecoveryHistory(sessionID) {
|
|
2486
2503
|
const messages = input.client?.session?.messages;
|
|
2487
2504
|
if (messages === undefined)
|
|
2488
2505
|
return undefined;
|
|
@@ -2494,55 +2511,40 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2494
2511
|
const payload = isRecord(response) && "data" in response ? response.data : response;
|
|
2495
2512
|
if (!Array.isArray(payload))
|
|
2496
2513
|
return undefined;
|
|
2514
|
+
let persistedTurn;
|
|
2515
|
+
let hasForeignUserTurn = false;
|
|
2497
2516
|
for (let index = payload.length - 1; index >= 0; index -= 1) {
|
|
2498
2517
|
const message = payload[index];
|
|
2499
|
-
if ((message
|
|
2500
|
-
continue;
|
|
2501
|
-
const agent = message.info?.agent ?? message.agent;
|
|
2502
|
-
if (typeof agent !== "string")
|
|
2518
|
+
if (!isRecord(message))
|
|
2503
2519
|
return undefined;
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
catch {
|
|
2512
|
-
return undefined;
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
async function hostSessionForeignUserAgent(sessionID) {
|
|
2516
|
-
const messages = input.client?.session?.messages;
|
|
2517
|
-
if (messages === undefined)
|
|
2518
|
-
return undefined;
|
|
2519
|
-
try {
|
|
2520
|
-
const response = await messages.call(input.client.session, {
|
|
2521
|
-
path: { id: sessionID },
|
|
2522
|
-
query: { directory: input.directory },
|
|
2523
|
-
});
|
|
2524
|
-
const payload = isRecord(response) && "data" in response ? response.data : response;
|
|
2525
|
-
if (!Array.isArray(payload))
|
|
2526
|
-
return undefined;
|
|
2527
|
-
for (const message of payload) {
|
|
2528
|
-
if ((message.info?.role ?? message.role) !== "user")
|
|
2520
|
+
if (message.info !== undefined && !isRecord(message.info))
|
|
2521
|
+
return undefined;
|
|
2522
|
+
const info = isRecord(message.info) ? message.info : undefined;
|
|
2523
|
+
const role = info?.role ?? message.role;
|
|
2524
|
+
if (role !== "user" && role !== "assistant")
|
|
2525
|
+
return undefined;
|
|
2526
|
+
if (role !== "user")
|
|
2529
2527
|
continue;
|
|
2530
|
-
const agent =
|
|
2528
|
+
const agent = info?.agent ?? message.agent;
|
|
2531
2529
|
if (typeof agent !== "string")
|
|
2532
2530
|
return undefined;
|
|
2533
2531
|
if (agent !== COORDINATOR_AGENT)
|
|
2534
|
-
|
|
2532
|
+
hasForeignUserTurn = true;
|
|
2533
|
+
if (persistedTurn === undefined) {
|
|
2534
|
+
if (message.parts !== undefined && !Array.isArray(message.parts))
|
|
2535
|
+
return undefined;
|
|
2536
|
+
persistedTurn = {
|
|
2537
|
+
agent,
|
|
2538
|
+
synthetic: Array.isArray(message.parts) && message.parts.some((part) => isRecord(part) && part.synthetic === true),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2535
2541
|
}
|
|
2536
|
-
return
|
|
2542
|
+
return { hasForeignUserTurn, persistedTurn };
|
|
2537
2543
|
}
|
|
2538
2544
|
catch {
|
|
2539
2545
|
return undefined;
|
|
2540
2546
|
}
|
|
2541
2547
|
}
|
|
2542
|
-
async function hostSessionHasForeignUserTurn(sessionID) {
|
|
2543
|
-
const agent = await hostSessionForeignUserAgent(sessionID);
|
|
2544
|
-
return typeof agent === "string" ? true : agent;
|
|
2545
|
-
}
|
|
2546
2548
|
async function assistantMessageText(sessionID, messageID, expectedAgent, partID) {
|
|
2547
2549
|
const messages = input.client?.session?.messages;
|
|
2548
2550
|
if (messages === undefined)
|
|
@@ -2600,10 +2602,13 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2600
2602
|
return false;
|
|
2601
2603
|
if (identity.agent !== undefined && identity.agent !== COORDINATOR_AGENT)
|
|
2602
2604
|
return false;
|
|
2603
|
-
|
|
2605
|
+
const history = await hostSessionRecoveryHistory(sessionID);
|
|
2606
|
+
if (history === undefined || history.hasForeignUserTurn)
|
|
2604
2607
|
return false;
|
|
2605
|
-
const persistedTurn =
|
|
2606
|
-
if (persistedTurn
|
|
2608
|
+
const persistedTurn = history.persistedTurn;
|
|
2609
|
+
if (persistedTurn === undefined && identity.agent !== COORDINATOR_AGENT)
|
|
2610
|
+
return false;
|
|
2611
|
+
if (persistedTurn !== undefined && persistedTurn.agent !== COORDINATOR_AGENT)
|
|
2607
2612
|
return false;
|
|
2608
2613
|
await rememberCoordinatorRoot(sessionID);
|
|
2609
2614
|
releaseSessionEnforcement(sessionID);
|
|
@@ -2899,6 +2904,12 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2899
2904
|
.replaceAll(CONTINUATION_MARKER, "")
|
|
2900
2905
|
.trimEnd();
|
|
2901
2906
|
}
|
|
2907
|
+
if ((isCoordinatorSession(textInput.sessionID) || await recoverCoordinatorRoot(textInput.sessionID)) &&
|
|
2908
|
+
isDoneTerminalText(textOutput.text)) {
|
|
2909
|
+
const metrics = await collectRunMetrics(input.client, textInput.sessionID, input.directory).catch(() => undefined);
|
|
2910
|
+
if (metrics !== undefined)
|
|
2911
|
+
textOutput.text = insertRunMetrics(textOutput.text, metrics);
|
|
2912
|
+
}
|
|
2902
2913
|
await completeContinuationText(textInput.sessionID, textOutput.text, false);
|
|
2903
2914
|
},
|
|
2904
2915
|
"experimental.session.compacting": async (compactInput, compactOutput) => {
|
|
@@ -3102,6 +3113,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
3102
3113
|
*/
|
|
3103
3114
|
"tool.execute.after": async (toolInput, output) => {
|
|
3104
3115
|
const completedChildSessionID = toolInput.tool === "task" ? taskChildSessionID(output) : undefined;
|
|
3116
|
+
const handoffInspection = inspectSuccessfulRead(toolInput);
|
|
3105
3117
|
try {
|
|
3106
3118
|
if (bootstrapRequired && toolInput.tool === "sortie_check_contract" && toolInput.sessionID !== undefined &&
|
|
3107
3119
|
isCoordinatorSession(toolInput.sessionID) && successfulBootstrapContractCheck(output)) {
|
|
@@ -3121,7 +3133,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
3121
3133
|
}
|
|
3122
3134
|
}
|
|
3123
3135
|
}
|
|
3124
|
-
await
|
|
3136
|
+
await handoffInspection;
|
|
3125
3137
|
let parallel = parallelCalls.get(toolInput.callID ?? "");
|
|
3126
3138
|
if (parallel === undefined && toolInput.tool === "task" && toolInput.sessionID !== undefined &&
|
|
3127
3139
|
toolInput.callID !== undefined &&
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface RunMetricsClient {
|
|
2
|
+
readonly session?: {
|
|
3
|
+
readonly get?: (request: {
|
|
4
|
+
path: {
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
7
|
+
query?: {
|
|
8
|
+
directory?: string;
|
|
9
|
+
};
|
|
10
|
+
}) => Promise<unknown>;
|
|
11
|
+
readonly children?: (request: {
|
|
12
|
+
path: {
|
|
13
|
+
id: string;
|
|
14
|
+
};
|
|
15
|
+
query?: {
|
|
16
|
+
directory?: string;
|
|
17
|
+
};
|
|
18
|
+
}) => Promise<unknown>;
|
|
19
|
+
readonly messages?: (request: {
|
|
20
|
+
path: {
|
|
21
|
+
id: string;
|
|
22
|
+
};
|
|
23
|
+
query?: {
|
|
24
|
+
directory?: string;
|
|
25
|
+
};
|
|
26
|
+
}) => Promise<unknown>;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface RunMetrics {
|
|
30
|
+
readonly durationMilliseconds: number | undefined;
|
|
31
|
+
readonly tokens: number | undefined;
|
|
32
|
+
readonly cost: number | undefined;
|
|
33
|
+
readonly steps: number | undefined;
|
|
34
|
+
readonly sessions: number | undefined;
|
|
35
|
+
readonly cacheRatio: number | undefined;
|
|
36
|
+
}
|
|
37
|
+
export declare function collectRunMetrics(client: RunMetricsClient | undefined, rootSessionID: string, directory?: string, now?: number): Promise<RunMetrics | undefined>;
|
|
38
|
+
export declare function formatRunMetrics(metrics: RunMetrics): string;
|
|
39
|
+
export declare function isDoneTerminalText(text: string): boolean;
|
|
40
|
+
export declare function insertRunMetrics(text: string, metrics: RunMetrics): string;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const MAX_SESSIONS = 128;
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === "object" ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
function unwrap(value) {
|
|
6
|
+
const object = record(value);
|
|
7
|
+
return object !== undefined && "data" in object ? object.data : value;
|
|
8
|
+
}
|
|
9
|
+
function number(value) {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
function messageTokens(message) {
|
|
13
|
+
const info = record(message.info) ?? message;
|
|
14
|
+
const tokens = record(info.tokens) ?? record(message.tokens);
|
|
15
|
+
if (tokens === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const input = number(tokens.input);
|
|
18
|
+
const output = number(tokens.output);
|
|
19
|
+
const reasoning = number(tokens.reasoning);
|
|
20
|
+
const cache = record(tokens.cache);
|
|
21
|
+
const cacheRead = number(cache?.read) ?? number(tokens.cacheRead) ?? number(tokens.cache_read);
|
|
22
|
+
const cacheWrite = number(cache?.write) ?? number(tokens.cacheWrite) ?? number(tokens.cache_write);
|
|
23
|
+
if (input === undefined || output === undefined || reasoning === undefined || cacheRead === undefined || cacheWrite === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
return [input + output + reasoning + cacheRead + cacheWrite, cacheRead];
|
|
26
|
+
}
|
|
27
|
+
function assistantMessages(value) {
|
|
28
|
+
const payload = unwrap(value);
|
|
29
|
+
if (!Array.isArray(payload))
|
|
30
|
+
return undefined;
|
|
31
|
+
return payload.filter((entry) => {
|
|
32
|
+
const item = record(entry);
|
|
33
|
+
const info = item === undefined ? undefined : record(item.info);
|
|
34
|
+
return item !== undefined && (info?.role ?? item.role) === "assistant";
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function collectRunMetrics(client, rootSessionID, directory, now = Date.now()) {
|
|
38
|
+
const session = client?.session;
|
|
39
|
+
if (session?.messages === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const ids = [rootSessionID];
|
|
42
|
+
const visited = new Set(ids);
|
|
43
|
+
let hierarchyComplete = session.children !== undefined;
|
|
44
|
+
for (let index = 0; index < ids.length && ids.length < MAX_SESSIONS; index += 1) {
|
|
45
|
+
if (session.children === undefined)
|
|
46
|
+
break;
|
|
47
|
+
try {
|
|
48
|
+
const children = unwrap(await session.children.call(session, { path: { id: ids[index] }, query: { directory } }));
|
|
49
|
+
if (!Array.isArray(children)) {
|
|
50
|
+
hierarchyComplete = false;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
for (const child of children) {
|
|
54
|
+
const item = record(child);
|
|
55
|
+
const id = typeof item?.id === "string" ? item.id : typeof item?.sessionID === "string" ? item.sessionID : undefined;
|
|
56
|
+
if (id === undefined) {
|
|
57
|
+
hierarchyComplete = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!visited.has(id) && ids.length < MAX_SESSIONS) {
|
|
61
|
+
visited.add(id);
|
|
62
|
+
ids.push(id);
|
|
63
|
+
}
|
|
64
|
+
else if (!visited.has(id))
|
|
65
|
+
hierarchyComplete = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
hierarchyComplete = false;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (ids.length >= MAX_SESSIONS)
|
|
74
|
+
hierarchyComplete = false;
|
|
75
|
+
const uniqueMessages = new Set();
|
|
76
|
+
let totalTokens = 0;
|
|
77
|
+
let cacheRead = 0;
|
|
78
|
+
let tokensAvailable = true;
|
|
79
|
+
let messagesComplete = true;
|
|
80
|
+
let steps = 0;
|
|
81
|
+
let cost = 0;
|
|
82
|
+
let costAvailable = true;
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
try {
|
|
85
|
+
const messages = assistantMessages(await session.messages.call(session, { path: { id }, query: { directory } }));
|
|
86
|
+
if (messages === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
for (const message of messages) {
|
|
89
|
+
const info = record(message.info) ?? message;
|
|
90
|
+
const time = record(info.time) ?? record(message.time);
|
|
91
|
+
if (time !== undefined && number(time.completed) === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
const messageID = typeof info.id === "string" ? info.id : typeof message.id === "string" ? message.id : undefined;
|
|
94
|
+
if (messageID === undefined) {
|
|
95
|
+
messagesComplete = false;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (uniqueMessages.has(messageID))
|
|
99
|
+
continue;
|
|
100
|
+
uniqueMessages.add(messageID);
|
|
101
|
+
steps += 1;
|
|
102
|
+
const tokens = messageTokens(message);
|
|
103
|
+
if (tokens !== undefined) {
|
|
104
|
+
totalTokens += tokens[0];
|
|
105
|
+
cacheRead += tokens[1];
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
tokensAvailable = false;
|
|
109
|
+
const reportedCost = number(info.cost) ?? number(message.cost);
|
|
110
|
+
if (reportedCost === undefined)
|
|
111
|
+
costAvailable = false;
|
|
112
|
+
else
|
|
113
|
+
cost += reportedCost;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let created;
|
|
121
|
+
if (session.get !== undefined) {
|
|
122
|
+
try {
|
|
123
|
+
const root = record(unwrap(await session.get.call(session, { path: { id: rootSessionID }, query: { directory } })));
|
|
124
|
+
const time = record(root?.time);
|
|
125
|
+
created = number(time?.created);
|
|
126
|
+
}
|
|
127
|
+
catch { /* fallback below */ }
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
durationMilliseconds: created === undefined ? undefined : Math.max(0, now - created),
|
|
131
|
+
tokens: hierarchyComplete && messagesComplete && tokensAvailable ? totalTokens : undefined,
|
|
132
|
+
cost: hierarchyComplete && messagesComplete && costAvailable ? cost : undefined,
|
|
133
|
+
steps: hierarchyComplete && messagesComplete ? steps : undefined,
|
|
134
|
+
sessions: hierarchyComplete ? ids.length : undefined,
|
|
135
|
+
cacheRatio: hierarchyComplete && messagesComplete && tokensAvailable && totalTokens > 0 ? cacheRead / totalTokens : undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function duration(milliseconds) {
|
|
139
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
140
|
+
if (seconds < 60)
|
|
141
|
+
return `${seconds}s`;
|
|
142
|
+
const minutes = Math.floor(seconds / 60);
|
|
143
|
+
return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
144
|
+
}
|
|
145
|
+
export function formatRunMetrics(metrics) {
|
|
146
|
+
const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
|
|
147
|
+
const cost = metrics.cost === undefined ? "cost unavailable" : `$${metrics.cost.toFixed(4)}`;
|
|
148
|
+
const tokens = metrics.tokens === undefined ? "tokens unavailable" : `${metrics.tokens.toLocaleString("en-US")} tokens`;
|
|
149
|
+
const steps = metrics.steps === undefined ? "steps unavailable" : `${metrics.steps} completed assistant model step${metrics.steps === 1 ? "" : "s"}`;
|
|
150
|
+
const sessions = metrics.sessions === undefined ? "sessions unavailable" : `${metrics.sessions} session${metrics.sessions === 1 ? "" : "s"}`;
|
|
151
|
+
const cache = metrics.cacheRatio === undefined ? "cache ratio unavailable" : `${(metrics.cacheRatio * 100).toFixed(1)}% cache ratio`;
|
|
152
|
+
return `**Run:** pre-terminal host snapshot · ${elapsed} · ${tokens} · ${cost} · ${steps} · ${sessions} · ${cache}`;
|
|
153
|
+
}
|
|
154
|
+
export function isDoneTerminalText(text) {
|
|
155
|
+
const first = text.split(/\r?\n/u).find((line) => line.trim().length > 0) ?? "";
|
|
156
|
+
return /^✅\s+\*\*DONE\*\*(?:\s|$)/u.test(first) || /^status:\s*DONE(?:\s|$)/u.test(first);
|
|
157
|
+
}
|
|
158
|
+
export function insertRunMetrics(text, metrics) {
|
|
159
|
+
if (/\*\*Run:\*\*/u.test(text) || !isDoneTerminalText(text))
|
|
160
|
+
return text;
|
|
161
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
162
|
+
const statusStart = text.search(/^(?:✅\s+\*\*DONE\*\*|status:\s*DONE).*$/mu);
|
|
163
|
+
if (statusStart < 0)
|
|
164
|
+
return text;
|
|
165
|
+
const statusEnd = text.indexOf(newline, statusStart);
|
|
166
|
+
if (statusEnd < 0)
|
|
167
|
+
return `${text}${newline}${newline}${formatRunMetrics(metrics)}`;
|
|
168
|
+
return `${text.slice(0, statusEnd + newline.length)}${newline}${formatRunMetrics(metrics)}${newline}${text.slice(statusEnd + newline.length)}`;
|
|
169
|
+
}
|