orizu 0.5.6 → 0.5.8

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/src/help.ts CHANGED
@@ -35,6 +35,7 @@ const GROUPS: CliGroupDoc[] = [
35
35
  { name: 'Teams', summary: 'Manage teams and team memberships.' },
36
36
  { name: 'Projects', summary: 'Manage projects inside teams.' },
37
37
  { name: 'Prompts and judges', summary: 'Work with versioned prompt and judge artifacts.' },
38
+ { name: 'Report comments', summary: 'Review and mutate anchored report comments across prompt, optimization, and task reports.' },
38
39
  { name: 'Scorers and runners', summary: 'Register scorers, push runnable artifacts, execute runners, and submit scores.' },
39
40
  { name: 'Optimizations', summary: 'Start, run, export, and finalize optimization runs.' },
40
41
  { name: 'Apps', summary: 'Create, preview, update, inspect, and export review apps.' },
@@ -82,6 +83,67 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
82
83
  summary: 'Submit an optimization event payload.',
83
84
  group: 'Optimizations',
84
85
  },
86
+ {
87
+ path: ['comments', 'list'],
88
+ usage: 'orizu comments list (--prompt <id-or-name> --project <team/project> [--label <label> | --version <id>] | --run <run-id> | --task <task-id>) [--json]',
89
+ summary: 'List report comment threads with open/resolved status and replies.',
90
+ group: 'Report comments',
91
+ options: [
92
+ { name: '--prompt <id-or-name>', help: 'Prompt report target. Requires --project.' },
93
+ { name: '--project <team/project>', help: 'Project slug for prompt targets.' },
94
+ { name: '--label <label>', help: 'Prompt version label to comment on.' },
95
+ { name: '--version <id>', help: 'Prompt version ID to comment on.' },
96
+ { name: '--run <run-id>', help: 'Optimization run report target.' },
97
+ { name: '--task <task-id>', help: 'Task report target.' },
98
+ { name: '--json', help: 'Emit the full comments payload.' },
99
+ ],
100
+ examples: [
101
+ 'orizu comments list --prompt Generator --project core/evals --label production',
102
+ 'orizu comments list --run <run-id>',
103
+ 'orizu comments list --task <task-id>',
104
+ ],
105
+ },
106
+ {
107
+ path: ['comments', 'add'],
108
+ usage: 'orizu comments add (--prompt <id-or-name> --project <team/project> [--label <label> | --version <id>] | --run <run-id> | --task <task-id>) --body <text|@file> [--anchor <text>] [--lines <start:end>] [--via <name>] [--json]',
109
+ summary: 'Add an anchored top-level comment to a report.',
110
+ group: 'Report comments',
111
+ options: [
112
+ { name: '--body <text|@file>', help: 'Comment body or @file reference.', required: true },
113
+ { name: '--anchor <text>', help: 'Quoted report text for the comment anchor.' },
114
+ { name: '--lines <start:end>', help: 'One-based inclusive line range for the anchor.' },
115
+ { name: '--via <name>', help: 'Tool or agent name to show in attribution.' },
116
+ { name: '--json', help: 'Emit the created comment payload.' },
117
+ ],
118
+ examples: [
119
+ 'orizu comments add --run <run-id> --body @comment.md --anchor "Score summary" --lines 4:6',
120
+ 'orizu comments add --task <task-id> --body "Clarify this finding" --via Codex',
121
+ ],
122
+ },
123
+ {
124
+ path: ['comments', 'reply'],
125
+ usage: 'orizu comments reply <comment-id> --body <text|@file> [--via <name>] [--json]',
126
+ summary: 'Reply to a top-level report comment thread.',
127
+ group: 'Report comments',
128
+ },
129
+ {
130
+ path: ['comments', 'resolve'],
131
+ usage: 'orizu comments resolve <comment-id> [--json]',
132
+ summary: 'Resolve a top-level report comment thread.',
133
+ group: 'Report comments',
134
+ },
135
+ {
136
+ path: ['comments', 'unresolve'],
137
+ usage: 'orizu comments unresolve <comment-id> [--json]',
138
+ summary: 'Reopen a resolved report comment thread.',
139
+ group: 'Report comments',
140
+ },
141
+ {
142
+ path: ['comments', 'edit'],
143
+ usage: 'orizu comments edit <comment-id> --body <text|@file> [--json]',
144
+ summary: 'Edit one of your report comments or replies.',
145
+ group: 'Report comments',
146
+ },
85
147
  {
86
148
  path: ['setup'],
87
149
  usage: 'orizu setup [--team <slug>] [--agent <claude|codex>]... [--workspace [path]|--no-workspace] [--validate] [--fix] [--no-symlinks] [--verbose] [--no-install] [--handoff|--no-handoff] [--launch <claude|codex>] [--skip-login] [--yes] [--dry-run] [--no-input|--non-interactive] [--json]',
@@ -125,7 +187,7 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
125
187
  },
126
188
  {
127
189
  path: ['install-skill'],
128
- usage: 'orizu install-skill [--agent <claude|codex>]... [--scope global|project] [--mode auto|link|copy] [--target <target>]... [--yes] [--dry-run]',
190
+ usage: 'orizu install-skill [--agent <claude|codex|opencode>]... [--scope global|project] [--mode auto|link|copy] [--target <target>]... [--yes] [--dry-run]',
129
191
  summary: 'Install or repair the Orizu coding-agent skill.',
130
192
  group: 'Agent setup',
131
193
  aliases: [['skills', 'install']],
@@ -273,12 +335,6 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
273
335
  summary: 'Restore an archived prompt artifact to active status.',
274
336
  group: 'Prompts and judges',
275
337
  },
276
- {
277
- path: ['prompts', 'comments'],
278
- usage: 'orizu prompts comments <prompt-id-or-name> --project <team/project> [--label <label> | --version <id>] [--json]',
279
- summary: 'List prompt comment threads with open/resolved status and replies.',
280
- group: 'Prompts and judges',
281
- },
282
338
  {
283
339
  path: ['prompts', 'pull'],
284
340
  usage: 'orizu prompts pull <prompt-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <id>] [--json]',
@@ -563,6 +619,20 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
563
619
  'orizu tasks report upload --task <taskId> --report @./report.md',
564
620
  ],
565
621
  },
622
+ {
623
+ path: ['tasks', 'report', 'get'],
624
+ usage: 'orizu tasks report get --task <taskId> [--json]',
625
+ summary: 'Read the Markdown report for a task.',
626
+ group: 'Tasks',
627
+ options: [
628
+ { name: '--task <taskId>', help: 'Task ID whose report should be read.', required: true },
629
+ { name: '--json', help: 'Emit the full report payload.' },
630
+ ],
631
+ examples: [
632
+ 'orizu tasks report get --task <taskId>',
633
+ 'orizu tasks report get --task <taskId> --json',
634
+ ],
635
+ },
566
636
  {
567
637
  path: ['tasks', 'pause'],
568
638
  usage: 'orizu tasks pause --task <taskId>',
@@ -441,6 +441,52 @@ export async function bootstrapHostedSandbox(opts: HostedBootstrapOptions): Prom
441
441
  })
442
442
  }
443
443
 
444
+ // 4b — Stage the orizu-cli skill into the workspace so the agent discovers it
445
+ // (ALI-1044). Resolve the CLI's OWN vendored skill dir in-sandbox, preferring:
446
+ // (1) $ORIZU_SKILL_SOURCE_DIR — explicit override (also honored by the CLI's
447
+ // skill-installer; the local-sim rehearsal sets it),
448
+ // (2) `orizu skills path` — the packaged vendor/skills/orizu-cli of the
449
+ // globally-installed CLI (the production path),
450
+ // (3) `$(npm root -g)/orizu/vendor/skills/orizu-cli` — belt fallback.
451
+ // Then SYMLINK it under <workspaceDir>/.claude/skills/orizu-cli, falling back
452
+ // to a copy if the symlink cannot be created. Non-fatal + recorded, exactly
453
+ // like the CLI install. Resolved paths stay in shell vars (never interpolated),
454
+ // so they cannot inject into the command.
455
+ currentStep = 'skill_staged'
456
+ const skillSkillsDirRel = `${workspaceDir}/.claude/skills`
457
+ const stage = await session.exec(
458
+ [
459
+ `mkdir -p ${skillSkillsDirRel}`,
460
+ `src="${'${ORIZU_SKILL_SOURCE_DIR:-}'}"`,
461
+ `if [ -z "$src" ] || [ ! -d "$src" ]; then src="$(orizu skills path 2>/dev/null || true)"; fi`,
462
+ `if [ -z "$src" ] || [ ! -d "$src" ]; then r="$(npm root -g 2>/dev/null || true)"; if [ -n "$r" ] && [ -d "$r/orizu/vendor/skills/orizu-cli" ]; then src="$r/orizu/vendor/skills/orizu-cli"; fi; fi`,
463
+ `if [ -z "$src" ] || [ ! -d "$src" ]; then echo "NO_SOURCE"; exit 0; fi`,
464
+ `dest="${skillSkillsDirRel}/orizu-cli"`,
465
+ `rm -rf "$dest"`,
466
+ `if ln -s "$src" "$dest" 2>/dev/null; then echo "SYMLINK $src"; else cp -R "$src" "$dest" && echo "COPY $src"; fi`,
467
+ ].join('\n')
468
+ )
469
+ const stageOut = stage.stdout.trim()
470
+ const stagedMethod = stageOut.startsWith('SYMLINK')
471
+ ? 'symlink'
472
+ : stageOut.startsWith('COPY')
473
+ ? 'copy'
474
+ : null
475
+ const stagedOk = stage.exitCode === 0 && stagedMethod !== null
476
+ record(
477
+ 'skill_staged',
478
+ stagedOk,
479
+ stagedOk
480
+ ? `orizu-cli skill staged (${stagedMethod})`
481
+ : `skill staging unresolved: ${stageOut || `exit ${stage.exitCode}`}`
482
+ )
483
+ await sink.append('skill_staged', {
484
+ ok: stagedOk,
485
+ method: stagedMethod,
486
+ dest: `${skillSkillsDirRel}/orizu-cli`,
487
+ outputTail: tail(stage.stdout, stage.stderr),
488
+ })
489
+
444
490
  // 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
445
491
  // P3-a: for enforced-egress providers the host DEFERS this hook to the loop,
446
492
  // which runs it AFTER the egress canary proves the firewall is live — so the
@@ -760,10 +760,32 @@ function errorEvent(messageId: string, error: unknown): HarnessEvent {
760
760
  export interface OpenCodeConfigOptions {
761
761
  /** Provider-qualified model string, e.g. "anthropic/claude-opus-4-8". */
762
762
  model: string
763
- /** OpenCode permission map; defaults to allow-all (sandbox is the boundary). */
763
+ /** OpenCode permission map; defaults to allow-all EXCEPT the headless-denied
764
+ * tools below (sandbox is the boundary for everything else). */
764
765
  permission?: Record<string, unknown>
765
766
  }
766
767
 
768
+ /**
769
+ * Tools DENIED in a hosted headless run (ALI-1037). OpenCode exposes a `question`
770
+ * permission key (docs/permissions) that gates the interactive "ask the user a
771
+ * question" tool. In an unattended sandbox there is no human to answer it, and
772
+ * OpenCode's only external reply endpoint — `POST /session/{id}/permissions/{permID}`
773
+ * — takes a BOOLEAN allow/deny, so it cannot select one of the tool's multiple
774
+ * options anyway. Denying the tool makes the model's ask fail fast (surfaced back
775
+ * to it as a tool error) so it proceeds autonomously per the standing preamble
776
+ * (HOSTED_TASK_PREAMBLE), instead of blocking the run forever. This is the
777
+ * documented fallback path in ALI-1037 (answer-externally is not cleanly possible).
778
+ */
779
+ export const HEADLESS_DENIED_TOOLS = ['question'] as const
780
+
781
+ /** The default headless permission map: allow everything, deny the interactive
782
+ * question tool (see HEADLESS_DENIED_TOOLS). */
783
+ export function defaultHeadlessPermission(): Record<string, unknown> {
784
+ const permission: Record<string, unknown> = { '*': { '*': 'allow' } }
785
+ for (const tool of HEADLESS_DENIED_TOOLS) permission[tool] = 'deny'
786
+ return permission
787
+ }
788
+
767
789
  /**
768
790
  * Build the `OPENCODE_CONFIG_CONTENT` payload OpenCode reads at boot (mirrors
769
791
  * OpenInspect's `start_opencode`). Kept pure so the launch command and the
@@ -772,7 +794,7 @@ export interface OpenCodeConfigOptions {
772
794
  export function buildOpenCodeConfigContent(opts: OpenCodeConfigOptions): string {
773
795
  return JSON.stringify({
774
796
  model: opts.model,
775
- permission: opts.permission ?? { '*': { '*': 'allow' } },
797
+ permission: opts.permission ?? defaultHeadlessPermission(),
776
798
  })
777
799
  }
778
800
 
@@ -79,6 +79,15 @@ export const HARNESS_EVENT_KINDS = {
79
79
  // and never terminal, so they append like any structural event.
80
80
  egress_blocked: 'egress_blocked',
81
81
  egress_allowed: 'egress_allowed',
82
+ // Durability + headless (ALI-1036 / ALI-1037). NOT produced by a harness — the
83
+ // in-sandbox loop emits these through the SAME single-writer run-event sink:
84
+ // the end-of-run auto-harvest result (work_persisted/work_none/work_persist_failed)
85
+ // and the headless question auto-handling record (question_auto_answered). They
86
+ // are ordinary structural events (never terminal), appended like any other.
87
+ work_persisted: 'work_persisted',
88
+ work_none: 'work_none',
89
+ work_persist_failed: 'work_persist_failed',
90
+ question_auto_answered: 'question_auto_answered',
82
91
  error: 'error',
83
92
  execution_complete: 'execution_complete',
84
93
  } as const
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Auto-harvest (ALI-1036) — deterministic, loop-side durability.
3
+ *
4
+ * At the END of a hosted run (BOTH the success and failure paths — a failed
5
+ * run's partial work is still valuable), the in-sandbox loop calls
6
+ * `harvestWorkspace` to make sure nothing the agent produced is lost:
7
+ *
8
+ * 1. `git status --porcelain` (plumbing) decides if the clone is dirty. Ignored
9
+ * files never appear in porcelain output, so a checkpoint is skipped when the
10
+ * only changes are ignored files.
11
+ * 2. If dirty: `git add -A` then `git commit` attributed to the agent identity
12
+ * (AGENT_GIT_IDENTITY) with message `checkpoint: run <runId> auto-harvest`.
13
+ * 3. If there is anything unpushed (a fresh checkpoint OR pre-existing local
14
+ * commits the agent made but did not push), `git push` to the session branch
15
+ * (the repo-local credential helper configured by bootstrap authorizes it).
16
+ * 4. Return a typed outcome the loop records as a run event: `work_persisted`
17
+ * (sha + files), `work_none` (clean, nothing to push), or `work_persist_failed`
18
+ * (error). Harvest NEVER throws — a harvest failure must not change the run's
19
+ * terminal status; it is recorded and the run proceeds to terminal.
20
+ *
21
+ * The loop runs IN the sandbox, so git is driven with `child_process` (matching
22
+ * `defaultRunSetupHook` / `installOpenCodePinned` in `hosted-loop.ts`). `exec` is
23
+ * injectable so the whole thing is unit-testable with no real git.
24
+ */
25
+
26
+ import { spawnSync } from 'child_process'
27
+
28
+ export interface HarvestExecResult {
29
+ exitCode: number
30
+ stdout: string
31
+ stderr: string
32
+ }
33
+
34
+ /** Injectable git runner. Defaults to a `spawnSync` git in the workspace dir. */
35
+ export type HarvestExec = (args: readonly string[]) => HarvestExecResult
36
+
37
+ export interface HarvestOptions {
38
+ /** Directory of the cloned session-branch workspace (the loop's cwd for git). */
39
+ workspaceDir: string
40
+ runId: string
41
+ /** Commit attribution (AGENT_GIT_IDENTITY from hosted-runtime-assets). */
42
+ author: { name: string; email: string }
43
+ /** Injectable git runner (default: real `git` via child_process). */
44
+ exec?: HarvestExec
45
+ /**
46
+ * Safety gate (review finding #1): the default exec runs REAL `git add/commit/
47
+ * push` in `workspaceDir`. Real harvest must be affirmatively enabled — the loop
48
+ * only enables it inside a genuine hosted sandbox (prebaked marker present). When
49
+ * false AND no `exec` is injected, harvest is a no-op (`work_none`) so an errant
50
+ * workspaceDir (a test, a dev run) can never commit/push a host repo. An injected
51
+ * `exec` always wins (tests that DO drive harvest).
52
+ */
53
+ enabled?: boolean
54
+ }
55
+
56
+ export type HarvestOutcome =
57
+ | { kind: 'work_persisted'; sha: string; files: string[] }
58
+ | { kind: 'work_none' }
59
+ | { kind: 'work_persist_failed'; error: string }
60
+
61
+ function defaultHarvestExec(workspaceDir: string): HarvestExec {
62
+ return (args: readonly string[]): HarvestExecResult => {
63
+ const res = spawnSync('git', args as string[], { cwd: workspaceDir, encoding: 'utf8' })
64
+ if (res.error) {
65
+ return { exitCode: 1, stdout: '', stderr: res.error.message }
66
+ }
67
+ return { exitCode: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }
68
+ }
69
+ }
70
+
71
+ function detail(res: HarvestExecResult): string {
72
+ const text = (res.stderr && res.stderr.trim()) || (res.stdout && res.stdout.trim()) || `exit ${res.exitCode}`
73
+ return text
74
+ }
75
+
76
+ function splitLines(text: string): string[] {
77
+ return text
78
+ .split('\n')
79
+ .map(line => line.trim())
80
+ .filter(line => line.length > 0)
81
+ }
82
+
83
+ /**
84
+ * Count commits present locally but not on the upstream tracking branch. Returns
85
+ * 0 when there is no upstream (`@{u}` unresolvable) — with no upstream a push
86
+ * could not land anyway, so "nothing to push" is the safe reading.
87
+ */
88
+ function countUnpushed(exec: HarvestExec): number {
89
+ const res = exec(['rev-list', '--count', '@{u}..HEAD'])
90
+ if (res.exitCode !== 0) return 0
91
+ const n = Number.parseInt(res.stdout.trim(), 10)
92
+ return Number.isFinite(n) ? n : 0
93
+ }
94
+
95
+ /**
96
+ * Persist any uncommitted or unpushed work in the workspace. Deterministic and
97
+ * total: every path returns a `HarvestOutcome`, never throws.
98
+ */
99
+ export function harvestWorkspace(opts: HarvestOptions): HarvestOutcome {
100
+ // Safety gate: never touch a real repo unless explicitly enabled or given an
101
+ // injected exec. Absent both, report nothing-to-do rather than run host git.
102
+ if (!opts.exec && !opts.enabled) {
103
+ return { kind: 'work_none' }
104
+ }
105
+ const exec = opts.exec ?? defaultHarvestExec(opts.workspaceDir)
106
+ try {
107
+ const status = exec(['status', '--porcelain'])
108
+ if (status.exitCode !== 0) {
109
+ return { kind: 'work_persist_failed', error: `git status failed: ${detail(status)}` }
110
+ }
111
+ const dirty = status.stdout.trim().length > 0
112
+
113
+ if (dirty) {
114
+ const add = exec(['add', '-A'])
115
+ if (add.exitCode !== 0) {
116
+ return { kind: 'work_persist_failed', error: `git add failed: ${detail(add)}` }
117
+ }
118
+ const commit = exec([
119
+ '-c',
120
+ `user.name=${opts.author.name}`,
121
+ '-c',
122
+ `user.email=${opts.author.email}`,
123
+ 'commit',
124
+ '--author',
125
+ `${opts.author.name} <${opts.author.email}>`,
126
+ '-m',
127
+ `checkpoint: run ${opts.runId} auto-harvest`,
128
+ ])
129
+ if (commit.exitCode !== 0) {
130
+ return { kind: 'work_persist_failed', error: `git commit failed: ${detail(commit)}` }
131
+ }
132
+ }
133
+
134
+ // Nothing to push when the tree was clean AND there are no local-only commits.
135
+ const unpushed = countUnpushed(exec)
136
+ if (!dirty && unpushed <= 0) {
137
+ return { kind: 'work_none' }
138
+ }
139
+
140
+ const headRes = exec(['rev-parse', 'HEAD'])
141
+ const sha = headRes.exitCode === 0 ? headRes.stdout.trim() : ''
142
+
143
+ // Files: the checkpoint commit's tree when we just committed, else everything
144
+ // spanned by the unpushed commits.
145
+ const filesRes = dirty
146
+ ? exec(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'])
147
+ : exec(['diff', '--name-only', '@{u}..HEAD'])
148
+ const files = filesRes.exitCode === 0 ? splitLines(filesRes.stdout) : []
149
+
150
+ const push = exec(['push'])
151
+ if (push.exitCode !== 0) {
152
+ return { kind: 'work_persist_failed', error: `git push failed: ${detail(push)}` }
153
+ }
154
+
155
+ return { kind: 'work_persisted', sha, files }
156
+ } catch (error) {
157
+ return { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) }
158
+ }
159
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Headless-run helpers (ALI-1037) for the in-sandbox loop.
3
+ *
4
+ * - `withIdleWatchdog`: wraps a harness event stream and aborts a prompt that
5
+ * makes NO progress (no harness event) for a bounded window, so a stalled
6
+ * agent can never hang a hosted run forever. On idle it invokes `onTimeout`
7
+ * (the loop aborts the harness) then throws `AgentStalledError`, which the
8
+ * loop turns into a `failed` terminal — AFTER running the end-of-run harvest.
9
+ *
10
+ * - `annotateHeadlessQuestions`: observability for the question-tool deny path.
11
+ * The interactive `question` tool is DENIED via OpenCode's permission map
12
+ * (see HEADLESS_DENIED_TOOLS in hosted-harness-opencode.ts), so a headless
13
+ * run never blocks on it. When the agent nonetheless attempts a `question`
14
+ * tool call, this wrapper emits a `question_auto_answered` event recording the
15
+ * question and the option that WOULD be recommended, so the run timeline shows
16
+ * the auto-handling. It touches only the normalized event stream — never the
17
+ * fragile OpenCode SSE transform.
18
+ */
19
+
20
+ import type { HarnessEvent } from './hosted-harness.js'
21
+
22
+ /** Thrown by `withIdleWatchdog` when the stream is idle past its timeout. */
23
+ export class AgentStalledError extends Error {
24
+ readonly idleMs: number
25
+ constructor(idleMs: number) {
26
+ super(`agent_stalled: no progress for ${formatIdleWindow(idleMs)}`)
27
+ this.name = 'AgentStalledError'
28
+ this.idleMs = idleMs
29
+ }
30
+ }
31
+
32
+ /** Render the idle window for the error message. Minutes (the operator-facing
33
+ * unit; default window is 10m), rounded to one decimal so sub-minute test
34
+ * windows still read sensibly. */
35
+ export function formatIdleWindow(ms: number): string {
36
+ const minutes = Math.round((ms / 60000) * 10) / 10
37
+ return `${minutes}m`
38
+ }
39
+
40
+ export interface IdleWatchdogOptions {
41
+ /** No harness event within this many ms aborts the prompt. <= 0 disables. */
42
+ timeoutMs: number
43
+ /** Invoked once on idle (the loop aborts the harness) before the throw. */
44
+ onTimeout: () => Promise<void> | void
45
+ }
46
+
47
+ const IDLE_SENTINEL = Symbol('idle')
48
+
49
+ /**
50
+ * Wrap `source` so that if no value arrives within `timeoutMs`, `onTimeout` runs
51
+ * and `AgentStalledError` is thrown. The idle timer resets on EVERY value, so it
52
+ * measures gaps between events (progress), not total duration. The still-pending
53
+ * `next()` is not left as an unhandled rejection, and the underlying iterator is
54
+ * returned/closed on any early exit.
55
+ */
56
+ export async function* withIdleWatchdog<T>(
57
+ source: AsyncIterable<T>,
58
+ opts: IdleWatchdogOptions
59
+ ): AsyncGenerator<T> {
60
+ const iterator = source[Symbol.asyncIterator]()
61
+ try {
62
+ for (;;) {
63
+ let timer: ReturnType<typeof setTimeout> | undefined
64
+ const idle = new Promise<typeof IDLE_SENTINEL>(resolve => {
65
+ timer = setTimeout(() => resolve(IDLE_SENTINEL), opts.timeoutMs)
66
+ })
67
+ const nextResult = iterator.next()
68
+ let raced: IteratorResult<T> | typeof IDLE_SENTINEL
69
+ try {
70
+ raced = await Promise.race([nextResult, idle])
71
+ } finally {
72
+ if (timer) clearTimeout(timer)
73
+ }
74
+ if (raced === IDLE_SENTINEL) {
75
+ // Swallow the abandoned next() so it never surfaces as an unhandled
76
+ // rejection once the underlying stream tears down.
77
+ void Promise.resolve(nextResult).catch(() => {})
78
+ await opts.onTimeout()
79
+ throw new AgentStalledError(opts.timeoutMs)
80
+ }
81
+ if (raced.done) return
82
+ yield raced.value
83
+ }
84
+ } finally {
85
+ if (iterator.return) {
86
+ try {
87
+ await iterator.return()
88
+ } catch {
89
+ // best-effort close
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // -- Headless question annotation --------------------------------------------
96
+
97
+ function optionText(option: unknown): string {
98
+ if (typeof option === 'string') return option
99
+ if (option && typeof option === 'object') {
100
+ const rec = option as Record<string, unknown>
101
+ for (const key of ['label', 'text', 'value', 'title', 'option', 'name']) {
102
+ if (typeof rec[key] === 'string') return rec[key] as string
103
+ }
104
+ }
105
+ return ''
106
+ }
107
+
108
+ export interface ParsedHeadlessQuestion {
109
+ question: string
110
+ chosenOption: string
111
+ }
112
+
113
+ /**
114
+ * Parse the OpenCode `question` tool args into the question text and the option
115
+ * we would pick headless: the one labeled "(Recommended)" if present, else the
116
+ * first. Defensive against the tool's shape drifting (options as strings or
117
+ * objects; the question under any of several keys).
118
+ */
119
+ export function parseHeadlessQuestion(args: unknown): ParsedHeadlessQuestion {
120
+ const rec = args && typeof args === 'object' ? (args as Record<string, unknown>) : {}
121
+ const questions = Array.isArray(rec.questions) ? rec.questions : []
122
+ const first = questions[0]
123
+
124
+ let question = ''
125
+ let options: unknown[] = []
126
+ if (typeof first === 'string') {
127
+ question = first
128
+ } else if (first && typeof first === 'object') {
129
+ const q = first as Record<string, unknown>
130
+ for (const key of ['question', 'prompt', 'text', 'title', 'message']) {
131
+ if (typeof q[key] === 'string') {
132
+ question = q[key] as string
133
+ break
134
+ }
135
+ }
136
+ if (Array.isArray(q.options)) options = q.options
137
+ }
138
+ if (options.length === 0 && Array.isArray(rec.options)) options = rec.options
139
+ if (!question && typeof rec.question === 'string') question = rec.question
140
+
141
+ const texts = options.map(optionText).filter(text => text.length > 0)
142
+ const recommended = texts.find(text => /\(recommended\)/i.test(text))
143
+ const chosenOption = recommended ?? texts[0] ?? ''
144
+ return { question, chosenOption }
145
+ }
146
+
147
+ /**
148
+ * Pass every harness event through unchanged; when a `question` tool call/result
149
+ * is seen, additionally emit a `question_auto_answered` event (deduped per
150
+ * callId+message) recording the auto-handling. The tool is denied at the
151
+ * permission layer, so this is a record — not an interactive reply.
152
+ */
153
+ export async function* annotateHeadlessQuestions(
154
+ source: AsyncIterable<HarnessEvent>
155
+ ): AsyncGenerator<HarnessEvent> {
156
+ const seen = new Set<string>()
157
+ for await (const event of source) {
158
+ yield event
159
+ if (event.kind !== 'tool_call' && event.kind !== 'tool_result') continue
160
+ const payload = event.payload as Record<string, unknown>
161
+ if (payload.tool !== 'question') continue
162
+ const callId = typeof payload.callId === 'string' ? payload.callId : ''
163
+ const key = `${callId}:${event.messageId ?? ''}`
164
+ if (seen.has(key)) continue
165
+ seen.add(key)
166
+ const parsed = parseHeadlessQuestion(payload.args)
167
+ yield {
168
+ kind: 'question_auto_answered',
169
+ messageId: event.messageId,
170
+ payload: {
171
+ question: parsed.question,
172
+ chosenOption: parsed.chosenOption,
173
+ note: 'auto-answered: headless run, no human available',
174
+ resolution: 'denied_headless',
175
+ },
176
+ }
177
+ }
178
+ }