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 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.5.16](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.16)
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
 
@@ -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.48-recovery-compaction-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.49-run-metrics-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -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.48-recovery-compaction-v1";
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
- throw new WorktreeCommitArtifactError("validation-failed", "Process-tree termination could not be confirmed.");
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) {
@@ -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: {
@@ -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
- await inspect(path, input.sessionID);
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 hostSessionUserTurn(sessionID) {
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.info?.role ?? message.role) !== "user")
2500
- continue;
2501
- const agent = message.info?.agent ?? message.agent;
2502
- if (typeof agent !== "string")
2518
+ if (!isRecord(message))
2503
2519
  return undefined;
2504
- return {
2505
- agent,
2506
- synthetic: (message.parts ?? []).some((part) => part.synthetic === true),
2507
- };
2508
- }
2509
- return undefined;
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 = message.info?.agent ?? message.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
- return agent;
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 false;
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
- if (await hostSessionHasForeignUserTurn(sessionID) !== false)
2605
+ const history = await hostSessionRecoveryHistory(sessionID);
2606
+ if (history === undefined || history.hasForeignUserTurn)
2604
2607
  return false;
2605
- const persistedTurn = await hostSessionUserTurn(sessionID);
2606
- if (persistedTurn?.agent !== COORDINATOR_AGENT)
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 inspectSuccessfulRead(toolInput);
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
+ }