opencode-workflow-guard 1.19.0 → 1.19.2

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.
@@ -2,14 +2,16 @@ import { realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import type { ShellMutation } from "../lib/types.ts";
5
+ import type { ShellSegmentPlan } from "../lib/shell.ts";
5
6
  import {
6
7
  getWorkspaceRoot,
7
8
  recordMutation,
8
9
  } from "../lib/state.ts";
9
- import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
10
+ import { isLeadingEvalSegment, maskDataHeredocBodies, prepareRedirectResidue, planShellSegments, shellWords, unwrapShellCommand } from "../lib/shell.ts";
10
11
  import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
11
12
  import { isSecretPath, secretIn } from "./secrets.ts";
12
- import { onProtectedBranch, branchGuardReason } from "./git.ts";
13
+ import { isGitTreeWriteText, branchGuardReason, onProtectedBranch, parseGitInvocation } from "./git.ts";
14
+ import { isSameGitRepo } from "../lib/project-config.ts";
13
15
  import {
14
16
  effectiveTodos,
15
17
  effectiveTodoOwnerSessionID,
@@ -372,8 +374,265 @@ function stripQuotedSpans(segment: string): string {
372
374
  return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
373
375
  }
374
376
 
377
+ /**
378
+ * A segment that changes a tree this session may not judge: a mutation or git
379
+ * write whose directory could not be resolved (`cd $DIR && git commit`) or
380
+ * whose directory is a foreign repository (`cd /other-repo && rm -rf dist`).
381
+ *
382
+ * Relative targets in such a segment would be validated against the session's
383
+ * own checkout while the command changes another tree. That is the defect a
384
+ * shell-level `cd` used to hide, so it is refused rather than judged.
385
+ */
386
+ export function foreignSegmentMutationReason(
387
+ segments: ShellSegmentPlan[],
388
+ workspaceRoot: string,
389
+ ): string | undefined {
390
+ for (const { segment, root } of segments) {
391
+ const mutation = detectShellMutation(segment);
392
+ const gitWrite = isGitTreeWrite(segment, root ?? workspaceRoot);
393
+ if (!mutation && !gitWrite) continue;
394
+ if (root === undefined) {
395
+ return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs a mutation (${mutation?.what ?? "git write"}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
396
+ }
397
+ if (!isPathOutsideWorkspace(root, workspaceRoot) || isSameGitRepo(root, workspaceRoot)) continue;
398
+ return `Blocked: the command changes into '${root}' and mutates it (${mutation?.what ?? "git write"}), but that directory is outside workspace root (${workspaceRoot}) and is not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ function isGitTreeWrite(segment: string, baseDir: string): boolean {
404
+ const invocation = parseGitInvocation(segment.trim(), baseDir);
405
+ if (!invocation) return false;
406
+ return isGitTreeWriteText(`git ${invocation.rest}`);
407
+ }
408
+
409
+ /**
410
+ * The working directory an inline interpreter payload names for the commands it
411
+ * runs.
412
+ *
413
+ * `execSync('git commit', {cwd: '<dir>'})` carries the tree directly instead of
414
+ * changing directory first, so the payload walk alone cannot see it. The match
415
+ * is anchored to a spawn-family call or `process.chdir`, because a bare `cwd:`
416
+ * key in an unrelated object literal does not choose a working directory.
417
+ *
418
+ * A named directory that is not a literal (`{cwd: process.env.OTHER}`) is
419
+ * reported as indeterminate rather than ignored, matching the shell rule that
420
+ * an unresolvable destination fails closed for a mutation.
421
+ */
422
+ export type PayloadWorkingRoot =
423
+ | { status: "none" }
424
+ | { status: "resolved"; root: string }
425
+ | { status: "indeterminate"; raw: string };
426
+
427
+ // Only a call site that actually spawns a process names a working directory.
428
+ // `execSync`, `spawn`, and friends are unambiguous. A bare `exec` is guarded by
429
+ // its preceding token so `regex.exec(str, {cwd: …})` is not a spawn, while
430
+ // `child_process.exec(...)` still is. A bare `cwd:` key in an unrelated object
431
+ // literal names no working directory at all.
432
+ const SPAWN_CALLEE = String.raw`(?:(?:child_process|cp)\.)?(?:execSync|execFileSync|spawnSync|execFile|spawn|fork)|(?:child_process|cp)\.exec|(?<![.\w$])exec`;
433
+ const PROCESS_CHDIR = /\bprocess\s*\.\s*chdir\s*\(\s*([^)]+?)\s*\)/;
434
+
435
+ /**
436
+ * The argument text of every spawn-family call, found with a balanced scan.
437
+ *
438
+ * A flat `[^)]*` stopped at the first closing paren, so
439
+ * `execSync(require('path').join('a','b'), {cwd: '/other'})` matched nothing and
440
+ * its foreign `cwd` went unchecked.
441
+ */
442
+ function spawnCallArguments(payload: string): string[] {
443
+ const spans: string[] = [];
444
+ const callee = new RegExp(String.raw`(?:${SPAWN_CALLEE})\s*\(`, "g");
445
+ for (let match = callee.exec(payload); match; match = callee.exec(payload)) {
446
+ let depth = 0;
447
+ let quote: string | undefined;
448
+ const start = match.index + match[0].length - 1;
449
+ for (let i = start; i < payload.length; i += 1) {
450
+ const char = payload[i]!;
451
+ if (quote) {
452
+ if (char === quote && payload[i - 1] !== "\\") quote = undefined;
453
+ continue;
454
+ }
455
+ if (char === "'" || char === '"' || char === "`") {
456
+ quote = char;
457
+ continue;
458
+ }
459
+ if (char === "(") depth += 1;
460
+ else if (char === ")") {
461
+ depth -= 1;
462
+ if (depth === 0) {
463
+ spans.push(payload.slice(start + 1, i));
464
+ break;
465
+ }
466
+ }
467
+ }
468
+ callee.lastIndex = Math.max(callee.lastIndex, start + 1);
469
+ }
470
+ return spans;
471
+ }
472
+
473
+ /**
474
+ * The `cwd` of the call's own options object, ignoring a nested one.
475
+ *
476
+ * The argument text is split at top-level commas, and only an argument that is
477
+ * itself an object literal contributes. Without that, `execSync(fn({cwd: '<a>'}),
478
+ * {cwd: '<b>'})` read the nested object's key first and reported a workspace
479
+ * directory for a call that actually ran in `<b>`.
480
+ */
481
+ function callOwnCwd(args: string): string | undefined {
482
+ let depth = 0;
483
+ let quote: string | undefined;
484
+ const topLevel: string[] = [];
485
+ let current = "";
486
+ for (let i = 0; i < args.length; i += 1) {
487
+ const char = args[i]!;
488
+ if (quote) {
489
+ if (char === quote && args[i - 1] !== "\\") quote = undefined;
490
+ current += char;
491
+ continue;
492
+ }
493
+ if (char === "'" || char === '"' || char === "`") {
494
+ quote = char;
495
+ current += char;
496
+ continue;
497
+ }
498
+ if (char === "{" || char === "[" || char === "(") depth += 1;
499
+ else if (char === "}" || char === "]" || char === ")") depth -= 1;
500
+ if (char === "," && depth === 0) {
501
+ topLevel.push(current);
502
+ current = "";
503
+ continue;
504
+ }
505
+ current += char;
506
+ }
507
+ topLevel.push(current);
508
+ for (const argument of topLevel) {
509
+ const trimmed = argument.trim();
510
+ if (!trimmed.startsWith("{")) continue;
511
+ const match = /\bcwd\s*:\s*([^,}]+)/.exec(trimmed);
512
+ if (match?.[1]) return match[1].trim();
513
+ }
514
+ return undefined;
515
+ }
516
+
517
+ export function payloadWorkingRoot(payload: string): PayloadWorkingRoot {
518
+ const cwdValues = spawnCallArguments(payload)
519
+ .map(callOwnCwd)
520
+ .filter((value): value is string => value !== undefined);
521
+ const chdir = PROCESS_CHDIR.exec(payload)?.[1]?.trim();
522
+ if (chdir) cwdValues.push(chdir);
523
+ // Every call's directory has to agree. Two different trees, or one that is
524
+ // not a literal, means the payload's real directory is not determinable, and
525
+ // a mutation then fails closed rather than trusting the first one found.
526
+ const roots = new Set<string>();
527
+ for (const raw of cwdValues) {
528
+ const literal = /^(["'`])([^"'`]*)\1$/.exec(raw);
529
+ if (!literal?.[2]) return { status: "indeterminate", raw };
530
+ roots.add(literal[2]);
531
+ }
532
+ if (roots.size === 0) return { status: "none" };
533
+ if (roots.size > 1) return { status: "indeterminate", raw: [...roots].join(", ") };
534
+ return { status: "resolved", root: [...roots][0]! };
535
+ }
536
+
537
+ function payloadMutates(payload: string): boolean {
538
+ if (detectShellMutation(payload)) return true;
539
+ return isGitTreeWriteText(payload);
540
+ }
541
+
542
+ /**
543
+ * A segment-level git invocation whose repository is outside the workspace.
544
+ *
545
+ * The dispatcher applies this check to top-level commands. A payload was never
546
+ * routed through it, so `bash -c 'git -C <other> commit'` reached a foreign
547
+ * repository: the `-C` form carries no `cd`, and `isGitTreeWriteText` reads the
548
+ * `git <verb>` text without resolving the flag.
549
+ */
550
+ function foreignGitRepositoryIn(segment: string, baseDir: string, workspaceRoot: string): string | undefined {
551
+ for (const raw of segment.split(/[\n|;&]+/)) {
552
+ const invocation = parseGitInvocation(raw.trim(), baseDir);
553
+ if (!invocation) continue;
554
+ if (!isGitTreeWriteText(`git ${invocation.rest}`)) continue;
555
+ const repoDir = invocation.repoDir;
556
+ const foreign = hasUnresolvableVariable(repoDir)
557
+ || (isPathOutsideWorkspace(repoDir, workspaceRoot) && !isSameGitRepo(repoDir, workspaceRoot));
558
+ if (foreign) return repoDir;
559
+ }
560
+ return undefined;
561
+ }
562
+
563
+ /**
564
+ * The tree a nested interpreter payload changes, when that tree is one this
565
+ * session may not judge.
566
+ *
567
+ * A payload never reaches the top-level segment walk: `bash -c 'cd <other> &&
568
+ * git commit'` is one shell word to the outer shell, so its nested `cd` and
569
+ * mutation were invisible. `extractPayloads` and `writesIn` are injected so
570
+ * this policy does not import the interpreter scanner back.
571
+ */
572
+ export function nestedPayloadMutationReason(
573
+ extractPayloads: (segment: string) => string[],
574
+ writesIn: (payload: string) => string[],
575
+ command: string,
576
+ baseDir: string,
577
+ workspaceRoot: string,
578
+ depth = 0,
579
+ ): string | undefined {
580
+ // Nesting deeper than this is not modelled. A launcher chain that deep is
581
+ // refused rather than skipped, so the cap cannot become a way through.
582
+ if (depth > 8) {
583
+ return `Blocked: inline interpreter scripts are nested deeper than the ${8} levels this check models, so the tree they mutate cannot be determined. Run the command from a single script file inside the workspace instead.`;
584
+ }
585
+ for (const { segment, root } of planShellSegments(command, baseDir, (s) => {
586
+ // `eval` runs in the current shell, so its body's `cd` moves later
587
+ // segments. Only an `eval` body counts here; another launcher's payload
588
+ // runs in a child process and cannot move this shell.
589
+ return isLeadingEvalSegment(s) ? extractPayloads(s)[0] : undefined;
590
+ })) {
591
+ const payloads = extractPayloads(segment);
592
+ if (payloads.length === 0) continue;
593
+ // A launcher whose directory is indeterminate must not be assumed to be
594
+ // the workspace root: `cd $DIR && node -e "writeFileSync('x')"` would then
595
+ // read as a workspace write. The payload's own relative targets cannot be
596
+ // validated against a tree this walk could not resolve, so a mutation
597
+ // fails closed, matching the shell rule for the same shape.
598
+ const launcherRoot = root;
599
+ if (launcherRoot === undefined) {
600
+ const mutates = payloads.some((payload) => writesIn(payload).length > 0 || payloadMutates(payload));
601
+ if (mutates) {
602
+ return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs an inline interpreter script that mutates. Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
603
+ }
604
+ continue;
605
+ }
606
+ for (const payload of payloads) {
607
+ const foreignRepo = foreignGitRepositoryIn(payload, launcherRoot, workspaceRoot);
608
+ if (foreignRepo) {
609
+ return `Blocked: the inline interpreter script runs a git write against '${foreignRepo}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Run the command from inside the workspace instead.`;
610
+ }
611
+ const nested = foreignSegmentMutationReason(planShellSegments(payload, launcherRoot), workspaceRoot);
612
+ if (nested) return nested;
613
+ // A payload can launch another interpreter: `bash -c "bash -c 'cd
614
+ // <other> && git commit'"` only extracts its own quoted body. Re-plan
615
+ // each payload until the nesting bottoms out.
616
+ const inner = nestedPayloadMutationReason(extractPayloads, writesIn, payload, launcherRoot, workspaceRoot, depth + 1);
617
+ if (inner) return inner;
618
+ const declared = payloadWorkingRoot(payload);
619
+ if (declared.status === "none") continue;
620
+ if (writesIn(payload).length === 0 && !payloadMutates(payload)) continue;
621
+ if (declared.status === "indeterminate") {
622
+ return `Blocked: the inline interpreter script runs a mutating command in a working directory that cannot be determined without executing the script (${declared.raw}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
623
+ }
624
+ const targetRoot = resolve(launcherRoot, declared.root);
625
+ if (!isPathOutsideWorkspace(targetRoot, workspaceRoot) || isSameGitRepo(targetRoot, workspaceRoot)) continue;
626
+ return `Blocked: the inline interpreter script runs a mutating command in '${targetRoot}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
627
+ }
628
+ }
629
+ return undefined;
630
+ }
631
+
375
632
  export function detectShellMutation(command: string): ShellMutation | undefined {
376
- for (const rawSegment of command.split(/[\n|;&]+/)) {
633
+ // A heredoc body no command executes is prose. Splitting it on its newlines
634
+ // read a documented command shape as a real one.
635
+ for (const rawSegment of maskDataHeredocBodies(command).split(/[\n|;&]+/)) {
377
636
  const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
378
637
  const simpleMutations = simpleFilesystemMutations(segment);
379
638
  if (simpleMutations.length > 0) return simpleMutations[0];
@@ -395,15 +654,39 @@ export async function guardShellMutation(
395
654
  command: string,
396
655
  sessionID: string | undefined,
397
656
  record = true,
657
+ shellRoot?: string,
658
+ evalBody?: (segment: string) => string | undefined,
398
659
  ): Promise<string | undefined> {
399
- const root = getWorkspaceRoot();
660
+ // Judge the tree the command RUNS in. A linked worktree of this repository
661
+ // is a legitimate workdir, and its branch or relative targets are not the
662
+ // session checkout's: validating against that checkout blocked a mutation
663
+ // in a feature worktree as if it happened on the primary `main`.
664
+ const workspaceRoot = getWorkspaceRoot();
665
+ const root = shellRoot ?? workspaceRoot;
666
+ const plan = planShellSegments(command, root, evalBody);
667
+ // The foreign-tree gate runs here too, so this function is safe when called
668
+ // on its own: an indeterminate or foreign `cd` is refused instead of silently
669
+ // falling back to judging relative targets against the wrong tree.
670
+ const foreignReason = foreignSegmentMutationReason(plan, workspaceRoot);
671
+ if (foreignReason) return foreignReason;
400
672
  let hasMutation = false;
401
- for (const rawSegment of command.split(/[\n|;&]+/)) {
673
+ for (const { segment, root: segmentRoot } of plan) {
674
+ // `cd` earlier in the command moves this segment, so its relative targets
675
+ // resolve against the tree it actually changes. An indeterminate `cd` was
676
+ // already refused above.
677
+ const runRoot = segmentRoot ?? root;
678
+ // Both trees are this workspace: the checkout the command runs in and the
679
+ // session's own checkout. Absolute paths into either are legitimate, so a
680
+ // target is outside only when it leaves both.
681
+ const outsideWorkspace = (target: string): boolean =>
682
+ isPathOutsideWorkspace(target, runRoot) && isPathOutsideWorkspace(target, workspaceRoot);
402
683
  // Same residue analysis as detectShellMutation: quoted arguments of
403
684
  // gh/glab/az PR/issue commands are command data, not shell redirects,
404
- // while unquoted redirects still get full validation below.
405
- const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
406
- const secretSource = secretSourceInFilesystemCommand(segment);
685
+ // while unquoted redirects still get full validation below. A heredoc body
686
+ // that no command executes is prose, so its lines are not judged either.
687
+ const masked = maskDataHeredocBodies(segment);
688
+ const cleanSegment = isCollaborationInvocation(masked) ? stripQuotedSpans(masked) : masked;
689
+ const secretSource = secretSourceInFilesystemCommand(cleanSegment);
407
690
  if (secretSource) {
408
691
  return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
409
692
  }
@@ -411,36 +694,36 @@ export async function guardShellMutation(
411
694
  // sources must respect the same boundaries as mutation targets: no
412
695
  // moving files in from outside the workspace, no moving protected
413
696
  // (settings/plugin) files to innocuous names.
414
- const transfer = filesystemTransferInfo(segment);
415
- if (transfer && shellWords(unwrapShellCommand(segment))[0] === "mv") {
697
+ const transfer = filesystemTransferInfo(cleanSegment);
698
+ if (transfer && shellWords(unwrapShellCommand(cleanSegment))[0] === "mv") {
416
699
  for (const source of transfer.sources) {
417
700
  if (isProtectedPath(source)) {
418
701
  return PROTECTED_PATH_REASON;
419
702
  }
420
- if (isPathOutsideWorkspace(source, root)) {
703
+ if (outsideWorkspace(source)) {
421
704
  if (hasUnresolvableVariable(source)) {
422
- return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
705
+ return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
423
706
  }
424
- return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
707
+ return `Blocked: mv would remove source '${source}' from outside the workspace root (${workspaceRoot}). File mutations must stay within the workspace.`;
425
708
  }
426
709
  }
427
710
  }
428
- const simpleMutations = simpleFilesystemMutations(segment);
429
- const teeTargets = teeTargetsIn(segment);
711
+ const simpleMutations = simpleFilesystemMutations(cleanSegment);
712
+ const teeTargets = teeTargetsIn(cleanSegment);
430
713
  const teeMutations: ShellMutation[] = teeTargets.map((target) => ({
431
714
  kind: "command" as const,
432
715
  target,
433
716
  what: `tee to '${target}'`,
434
717
  }));
435
- const redirectMutations = redirectMutationsIn(segment.trim());
718
+ const redirectMutations = redirectMutationsIn(cleanSegment.trim());
436
719
  const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
437
720
  if (mutations.length === 0) {
438
- const fallbackMutation = shellMutationIn(segment.trim());
721
+ const fallbackMutation = shellMutationIn(cleanSegment.trim());
439
722
  if (fallbackMutation) mutations.push(fallbackMutation);
440
723
  }
441
724
  for (const mutation of mutations) {
442
725
  hasMutation = true;
443
- const secret = secretIn(segment);
726
+ const secret = secretIn(cleanSegment);
444
727
  if (secret) {
445
728
  return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
446
729
  }
@@ -448,19 +731,19 @@ export async function guardShellMutation(
448
731
  if (target && isProtectedPath(target)) {
449
732
  return PROTECTED_PATH_REASON;
450
733
  }
451
- if (target && isPathOutsideWorkspace(target, root)) {
734
+ if (target && outsideWorkspace(target)) {
452
735
  // The workspace boundary has no override: a write outside the
453
736
  // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
454
737
  // (that override covers live-system commands, not the boundary).
455
738
  if (hasUnresolvableVariable(target)) {
456
- return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
739
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
457
740
  }
458
- if (isEnvironmentScratchTarget(target, root)) {
459
- return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${root}) instead (for example .tmp-notes.md).`;
741
+ if (isEnvironmentScratchTarget(target, runRoot)) {
742
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${workspaceRoot}) instead (for example .tmp-notes.md).`;
460
743
  }
461
- return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
744
+ return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
462
745
  }
463
- if (onProtectedBranch(root)) {
746
+ if (onProtectedBranch(runRoot)) {
464
747
  return branchGuardReason();
465
748
  }
466
749
  const todos = await effectiveTodos(sessionID);
@@ -19,6 +19,19 @@ type ContinuationState = {
19
19
  export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
20
20
  const states = new WeakMap<object, Map<string, ContinuationState>>();
21
21
 
22
+ /**
23
+ * Whether the active client is the V2 shim, which can carry `delivery` and
24
+ * `resume` on a synthetic continuation. `session.synthetic` is the
25
+ * discriminator: the V2 shim supplies it, and the V1 SDK client does not
26
+ * declare it. `session.todo` is required alongside it only because this module
27
+ * reads todos through the same surface, so a client missing it cannot be the
28
+ * V2 shim for our purposes.
29
+ */
30
+ function isV2Host(): boolean {
31
+ const session = getSdkClient()?.session as { synthetic?: unknown; todo?: unknown } | undefined;
32
+ return typeof session?.synthetic === "function" && typeof session?.todo === "function";
33
+ }
34
+
22
35
  export async function waitForSessionTitle(sessionID: string): Promise<void> {
23
36
  const session = getSdkClient()?.session;
24
37
  if (typeof session?.get !== "function") return;
@@ -95,17 +108,34 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
95
108
  : "";
96
109
 
97
110
  try {
98
- await session.promptAsync({
99
- path: { id: sessionID },
100
- body: {
101
- messageID,
102
- parts: [{
103
- type: "text",
104
- text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run. Check guard_status for outstanding verification/review requirements, and guard_review_rubric before final completion.`,
105
- synthetic: true,
106
- }],
107
- },
108
- });
111
+ // Delivery and resume are stated explicitly so a V2 synthetic
112
+ // continuation does not depend on host defaults. `queue` keeps it in
113
+ // the session's inbox instead of steering an in-flight turn, and
114
+ // `resume: true` starts a run when the session is idle.
115
+ //
116
+ // The fields are V2-only. The V1 SDK body type declares neither
117
+ // (`SessionPromptAsyncData` in @opencode-ai/sdk), so adding them
118
+ // there would send unmodelled fields to a strict server. The V2
119
+ // client shim reads them off this same object, so the values travel
120
+ // without changing the V1 request shape.
121
+ const promptBody: {
122
+ messageID: string;
123
+ parts: Array<{ type: "text"; text: string; synthetic?: boolean }>;
124
+ delivery?: "steer" | "queue";
125
+ resume?: boolean;
126
+ } = {
127
+ messageID,
128
+ parts: [{
129
+ type: "text" as const,
130
+ text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run. Check guard_status for outstanding verification/review requirements, and guard_review_rubric before final completion.`,
131
+ synthetic: true,
132
+ }],
133
+ };
134
+ if (isV2Host()) {
135
+ promptBody.delivery = "queue";
136
+ promptBody.resume = true;
137
+ }
138
+ await session.promptAsync({ path: { id: sessionID }, body: promptBody });
109
139
  return true;
110
140
  } catch {
111
141
  state.counts.set(sessionID, count);