sortie-dogs 0.5.16 → 0.5.17

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.5.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.17)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -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) {
@@ -919,6 +919,7 @@ export const SortieDogsPlugin = async (input, options) => {
919
919
  return loaded?.gate === undefined && manifestAbsent;
920
920
  }
921
921
  const inspected = new Map();
922
+ const inspectionOperations = new Map();
922
923
  const sessionAuthorizations = new Map();
923
924
  const bindingPins = new Map();
924
925
  const bindingOperations = new Set();
@@ -1383,8 +1384,8 @@ export const SortieDogsPlugin = async (input, options) => {
1383
1384
  if (JSON.stringify(running.artifact.validation.command) !== JSON.stringify(requestedCommand)) {
1384
1385
  return deny("artifact-replay");
1385
1386
  }
1386
- await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
1387
1387
  await removeParallelControlFiles(binding.descriptor);
1388
+ await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
1388
1389
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact: running.artifact });
1389
1390
  pruneParallelChildMap(parallelArtifacts);
1390
1391
  return JSON.stringify({ status: "created", replay: true, artifact: boundedParallelArtifact(running.artifact) });
@@ -1397,8 +1398,8 @@ export const SortieDogsPlugin = async (input, options) => {
1397
1398
  };
1398
1399
  const recovered = await recoverWorktreeCommitArtifact(produceRequest);
1399
1400
  const artifact = recovered ?? await produceWorktreeCommitArtifact(produceRequest);
1400
- await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
1401
1401
  await removeParallelControlFiles(binding.descriptor);
1402
+ await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
1402
1403
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact });
1403
1404
  pruneParallelChildMap(parallelArtifacts);
1404
1405
  return JSON.stringify({ status: "created", ...(recovered === undefined ? {} : { replay: true }),
@@ -1953,6 +1954,11 @@ export const SortieDogsPlugin = async (input, options) => {
1953
1954
  return deny("binding-replay");
1954
1955
  }
1955
1956
  const existingAuthorization = sessionAuthorizations.get(sessionID);
1957
+ const pendingInspections = [...inspectionOperations.entries()]
1958
+ .filter(([key]) => key.startsWith(`${sessionID}\u0000`))
1959
+ .map(([, operation]) => operation);
1960
+ if (pendingInspections.length > 0)
1961
+ await Promise.allSettled(pendingInspections);
1956
1962
  pruneInspections(now);
1957
1963
  const inspectedEntry = [...inspected.entries()].find(([key, entry]) => key.startsWith(`${sessionID}\u0000`) &&
1958
1964
  entry.ownerSessionID === sessionID &&
@@ -2401,7 +2407,17 @@ export const SortieDogsPlugin = async (input, options) => {
2401
2407
  const path = input.args.filePath;
2402
2408
  if (typeof path !== "string" || path.length === 0)
2403
2409
  return;
2404
- await inspect(path, input.sessionID);
2410
+ const absolutePath = resolve(path);
2411
+ const key = `${input.sessionID}\u0000${absolutePath}`;
2412
+ const operation = inspect(path, input.sessionID).then(() => undefined);
2413
+ inspectionOperations.set(key, operation);
2414
+ try {
2415
+ await operation;
2416
+ }
2417
+ finally {
2418
+ if (inspectionOperations.get(key) === operation)
2419
+ inspectionOperations.delete(key);
2420
+ }
2405
2421
  }
2406
2422
  async function invalidateEditedHandoff(path) {
2407
2423
  await ensureLoaded();
@@ -2482,7 +2498,7 @@ export const SortieDogsPlugin = async (input, options) => {
2482
2498
  return undefined;
2483
2499
  }
2484
2500
  }
2485
- async function hostSessionUserTurn(sessionID) {
2501
+ async function hostSessionRecoveryHistory(sessionID) {
2486
2502
  const messages = input.client?.session?.messages;
2487
2503
  if (messages === undefined)
2488
2504
  return undefined;
@@ -2494,55 +2510,40 @@ export const SortieDogsPlugin = async (input, options) => {
2494
2510
  const payload = isRecord(response) && "data" in response ? response.data : response;
2495
2511
  if (!Array.isArray(payload))
2496
2512
  return undefined;
2513
+ let persistedTurn;
2514
+ let hasForeignUserTurn = false;
2497
2515
  for (let index = payload.length - 1; index >= 0; index -= 1) {
2498
2516
  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")
2517
+ if (!isRecord(message))
2503
2518
  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")
2519
+ if (message.info !== undefined && !isRecord(message.info))
2520
+ return undefined;
2521
+ const info = isRecord(message.info) ? message.info : undefined;
2522
+ const role = info?.role ?? message.role;
2523
+ if (role !== "user" && role !== "assistant")
2524
+ return undefined;
2525
+ if (role !== "user")
2529
2526
  continue;
2530
- const agent = message.info?.agent ?? message.agent;
2527
+ const agent = info?.agent ?? message.agent;
2531
2528
  if (typeof agent !== "string")
2532
2529
  return undefined;
2533
2530
  if (agent !== COORDINATOR_AGENT)
2534
- return agent;
2531
+ hasForeignUserTurn = true;
2532
+ if (persistedTurn === undefined) {
2533
+ if (message.parts !== undefined && !Array.isArray(message.parts))
2534
+ return undefined;
2535
+ persistedTurn = {
2536
+ agent,
2537
+ synthetic: Array.isArray(message.parts) && message.parts.some((part) => isRecord(part) && part.synthetic === true),
2538
+ };
2539
+ }
2535
2540
  }
2536
- return false;
2541
+ return { hasForeignUserTurn, persistedTurn };
2537
2542
  }
2538
2543
  catch {
2539
2544
  return undefined;
2540
2545
  }
2541
2546
  }
2542
- async function hostSessionHasForeignUserTurn(sessionID) {
2543
- const agent = await hostSessionForeignUserAgent(sessionID);
2544
- return typeof agent === "string" ? true : agent;
2545
- }
2546
2547
  async function assistantMessageText(sessionID, messageID, expectedAgent, partID) {
2547
2548
  const messages = input.client?.session?.messages;
2548
2549
  if (messages === undefined)
@@ -2600,10 +2601,13 @@ export const SortieDogsPlugin = async (input, options) => {
2600
2601
  return false;
2601
2602
  if (identity.agent !== undefined && identity.agent !== COORDINATOR_AGENT)
2602
2603
  return false;
2603
- if (await hostSessionHasForeignUserTurn(sessionID) !== false)
2604
+ const history = await hostSessionRecoveryHistory(sessionID);
2605
+ if (history === undefined || history.hasForeignUserTurn)
2606
+ return false;
2607
+ const persistedTurn = history.persistedTurn;
2608
+ if (persistedTurn === undefined && identity.agent !== COORDINATOR_AGENT)
2604
2609
  return false;
2605
- const persistedTurn = await hostSessionUserTurn(sessionID);
2606
- if (persistedTurn?.agent !== COORDINATOR_AGENT)
2610
+ if (persistedTurn !== undefined && persistedTurn.agent !== COORDINATOR_AGENT)
2607
2611
  return false;
2608
2612
  await rememberCoordinatorRoot(sessionID);
2609
2613
  releaseSessionEnforcement(sessionID);
@@ -3102,6 +3106,7 @@ export const SortieDogsPlugin = async (input, options) => {
3102
3106
  */
3103
3107
  "tool.execute.after": async (toolInput, output) => {
3104
3108
  const completedChildSessionID = toolInput.tool === "task" ? taskChildSessionID(output) : undefined;
3109
+ const handoffInspection = inspectSuccessfulRead(toolInput);
3105
3110
  try {
3106
3111
  if (bootstrapRequired && toolInput.tool === "sortie_check_contract" && toolInput.sessionID !== undefined &&
3107
3112
  isCoordinatorSession(toolInput.sessionID) && successfulBootstrapContractCheck(output)) {
@@ -3121,7 +3126,7 @@ export const SortieDogsPlugin = async (input, options) => {
3121
3126
  }
3122
3127
  }
3123
3128
  }
3124
- await inspectSuccessfulRead(toolInput);
3129
+ await handoffInspection;
3125
3130
  let parallel = parallelCalls.get(toolInput.callID ?? "");
3126
3131
  if (parallel === undefined && toolInput.tool === "task" && toolInput.sessionID !== undefined &&
3127
3132
  toolInput.callID !== undefined &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "description": "Bounded, validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",