orizu 0.5.26 → 0.5.28

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.
Files changed (164) hide show
  1. package/README.md +19 -0
  2. package/dist/artifacts-git-runtime.d.ts +34 -0
  3. package/dist/artifacts-git-runtime.d.ts.map +1 -0
  4. package/dist/artifacts-git-runtime.js +312 -0
  5. package/dist/cloudflare-artifacts-git-remote.d.ts +5 -0
  6. package/dist/cloudflare-artifacts-git-remote.d.ts.map +1 -0
  7. package/dist/cloudflare-artifacts-git-remote.js +21 -0
  8. package/dist/dataset-transfer-download.d.ts +2 -0
  9. package/dist/dataset-transfer-download.d.ts.map +1 -0
  10. package/dist/dataset-transfer-download.js +66 -0
  11. package/dist/dataset-transfer-policy.d.ts +2 -0
  12. package/dist/dataset-transfer-policy.d.ts.map +1 -0
  13. package/dist/dataset-transfer-policy.js +8 -0
  14. package/dist/dataset-transfer-upload.d.ts +42 -0
  15. package/dist/dataset-transfer-upload.d.ts.map +1 -0
  16. package/dist/dataset-transfer-upload.js +110 -0
  17. package/dist/git-credential.d.ts.map +1 -1
  18. package/dist/git-credential.js +1 -1
  19. package/dist/git-recovery-environment.d.ts +1 -0
  20. package/dist/git-recovery-environment.d.ts.map +1 -1
  21. package/dist/git-recovery-environment.js +1 -1
  22. package/dist/help.d.ts.map +1 -1
  23. package/dist/help.js +44 -11
  24. package/dist/hosted-boot.d.ts +75 -2
  25. package/dist/hosted-boot.d.ts.map +1 -1
  26. package/dist/hosted-boot.js +585 -102
  27. package/dist/hosted-bootstrap.d.ts +2 -0
  28. package/dist/hosted-bootstrap.d.ts.map +1 -1
  29. package/dist/hosted-bootstrap.js +12 -3
  30. package/dist/hosted-harness-opencode.d.ts +8 -9
  31. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  32. package/dist/hosted-harness-opencode.js +126 -24
  33. package/dist/hosted-harness.d.ts +3 -0
  34. package/dist/hosted-harness.d.ts.map +1 -1
  35. package/dist/hosted-harness.js +4 -1
  36. package/dist/hosted-harvest.d.ts +30 -9
  37. package/dist/hosted-harvest.d.ts.map +1 -1
  38. package/dist/hosted-harvest.js +222 -28
  39. package/dist/hosted-loop-lifecycle.d.ts +15 -3
  40. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  41. package/dist/hosted-loop-lifecycle.js +6 -4
  42. package/dist/hosted-loop.d.ts +47 -7
  43. package/dist/hosted-loop.d.ts.map +1 -1
  44. package/dist/hosted-loop.js +310 -64
  45. package/dist/hosted-question-limits.d.ts +7 -0
  46. package/dist/hosted-question-limits.d.ts.map +1 -0
  47. package/dist/hosted-question-limits.js +6 -0
  48. package/dist/hosted-question.d.ts +27 -0
  49. package/dist/hosted-question.d.ts.map +1 -0
  50. package/dist/hosted-question.js +118 -0
  51. package/dist/hosted-run-event-sink.d.ts +10 -5
  52. package/dist/hosted-run-event-sink.d.ts.map +1 -1
  53. package/dist/hosted-run-event-sink.js +12 -8
  54. package/dist/hosted-runtime-assets.d.ts +16 -7
  55. package/dist/hosted-runtime-assets.d.ts.map +1 -1
  56. package/dist/hosted-runtime-assets.js +40 -12
  57. package/dist/hosted-session-cli.d.ts.map +1 -1
  58. package/dist/hosted-session-cli.js +10 -19
  59. package/dist/http.d.ts +1 -1
  60. package/dist/http.d.ts.map +1 -1
  61. package/dist/http.js +10 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +113 -92
  64. package/dist/instruction-set-gepa-launch.d.ts +1 -1
  65. package/dist/instruction-set-gepa-launch.d.ts.map +1 -1
  66. package/dist/instruction-set-gepa-launch.js +5 -3
  67. package/dist/instruction-set-loader.d.ts.map +1 -1
  68. package/dist/instruction-set-loader.js +63 -2
  69. package/dist/instruction-set-lock/index.d.ts +51 -0
  70. package/dist/instruction-set-lock/index.d.ts.map +1 -0
  71. package/dist/instruction-set-lock/index.js +459 -0
  72. package/dist/instruction-set-lock/schema.v1.d.ts +136 -0
  73. package/dist/instruction-set-lock/schema.v1.d.ts.map +1 -0
  74. package/dist/instruction-set-lock/schema.v1.js +155 -0
  75. package/dist/instruction-set-provenance.d.ts +26 -0
  76. package/dist/instruction-set-provenance.d.ts.map +1 -0
  77. package/dist/instruction-set-provenance.js +68 -0
  78. package/dist/instruction-set-sync/helpers.d.ts +22 -0
  79. package/dist/instruction-set-sync/helpers.d.ts.map +1 -0
  80. package/dist/instruction-set-sync/helpers.js +677 -0
  81. package/dist/instruction-set-sync/index.d.ts +60 -0
  82. package/dist/instruction-set-sync/index.d.ts.map +1 -0
  83. package/dist/instruction-set-sync/index.js +689 -0
  84. package/dist/instruction-set-update/index.d.ts +42 -0
  85. package/dist/instruction-set-update/index.d.ts.map +1 -0
  86. package/dist/instruction-set-update/index.js +445 -0
  87. package/dist/instruction-set-verify/index.d.ts +27 -0
  88. package/dist/instruction-set-verify/index.d.ts.map +1 -0
  89. package/dist/instruction-set-verify/index.js +865 -0
  90. package/dist/instruction-sets-cli.d.ts +1 -4
  91. package/dist/instruction-sets-cli.d.ts.map +1 -1
  92. package/dist/instruction-sets-cli.js +220 -109
  93. package/dist/model-config-fields.d.ts +35 -0
  94. package/dist/model-config-fields.d.ts.map +1 -0
  95. package/dist/model-config-fields.js +28 -0
  96. package/dist/optimizations-list-cli.d.ts.map +1 -1
  97. package/dist/optimizations-list-cli.js +7 -4
  98. package/dist/score-format.d.ts +2 -0
  99. package/dist/score-format.d.ts.map +1 -0
  100. package/dist/score-format.js +18 -0
  101. package/dist/workbench-repo-contract.d.ts +65 -0
  102. package/dist/workbench-repo-contract.d.ts.map +1 -0
  103. package/dist/workbench-repo-contract.js +250 -0
  104. package/dist/workspace-bootstrap-job.d.ts +45 -0
  105. package/dist/workspace-bootstrap-job.d.ts.map +1 -0
  106. package/dist/workspace-bootstrap-job.js +431 -0
  107. package/dist/workspace-bootstrap-seed.d.ts +74 -0
  108. package/dist/workspace-bootstrap-seed.d.ts.map +1 -0
  109. package/dist/workspace-bootstrap-seed.js +209 -0
  110. package/package.json +4 -3
  111. package/src/artifacts-git-runtime.ts +360 -0
  112. package/src/cloudflare-artifacts-git-remote.ts +24 -0
  113. package/src/dataset-transfer-download.ts +61 -0
  114. package/src/dataset-transfer-policy.ts +8 -0
  115. package/src/dataset-transfer-upload.ts +145 -0
  116. package/src/git-credential.ts +1 -2
  117. package/src/git-recovery-environment.ts +1 -1
  118. package/src/help.ts +44 -11
  119. package/src/hosted-boot.ts +644 -109
  120. package/src/hosted-bootstrap.ts +14 -3
  121. package/src/hosted-harness-opencode.ts +130 -28
  122. package/src/hosted-harness.ts +4 -1
  123. package/src/hosted-harvest.ts +258 -33
  124. package/src/hosted-loop-lifecycle.ts +19 -5
  125. package/src/hosted-loop.ts +340 -68
  126. package/src/hosted-question-limits.ts +6 -0
  127. package/src/hosted-question.ts +145 -0
  128. package/src/hosted-run-event-sink.ts +24 -12
  129. package/src/hosted-runtime-assets.ts +51 -11
  130. package/src/hosted-session-cli.ts +10 -24
  131. package/src/http.ts +10 -4
  132. package/src/index.ts +108 -134
  133. package/src/instruction-set-gepa-launch.ts +4 -4
  134. package/src/instruction-set-loader.ts +53 -2
  135. package/src/instruction-set-lock/index.ts +561 -0
  136. package/src/instruction-set-lock/schema.v1.json +141 -0
  137. package/src/instruction-set-lock/schema.v1.ts +155 -0
  138. package/src/instruction-set-provenance.ts +89 -0
  139. package/src/instruction-set-sync/helpers.ts +707 -0
  140. package/src/instruction-set-sync/index.ts +791 -0
  141. package/src/instruction-set-update/index.ts +488 -0
  142. package/src/instruction-set-verify/index.ts +1067 -0
  143. package/src/instruction-sets-cli.ts +226 -114
  144. package/src/model-config-fields.ts +31 -0
  145. package/src/optimizations-list-cli.ts +7 -4
  146. package/src/score-format.ts +16 -0
  147. package/src/workbench-repo-contract.ts +314 -0
  148. package/src/workspace-bootstrap-job.ts +611 -0
  149. package/src/workspace-bootstrap-seed.ts +350 -0
  150. package/vendor/orizu-gepa/src/orizu_gepa_connector/instruction_set_loader.py +46 -2
  151. package/vendor/skills/orizu/SKILL.md +4 -2
  152. package/vendor/skills/orizu/flows/promote.md +4 -0
  153. package/vendor/skills/orizu/references/assess-and-plan.md +1 -1
  154. package/vendor/skills/orizu/references/authority-map.md +1 -1
  155. package/vendor/skills/orizu/references/cli-reference.md +151 -35
  156. package/vendor/skills/orizu/references/instructions-after-prompts.md +3 -3
  157. package/vendor/skills/orizu/references/migrate-existing-gepa-setup.md +5 -6
  158. package/vendor/skills/orizu/references/migrate-hand-rolled-instruction-layout.md +252 -0
  159. package/vendor/skills/orizu/references/optimization-reports.md +5 -4
  160. package/vendor/skills/orizu/references/optimization-with-gepa.md +1 -1
  161. package/vendor/skills/orizu/references/orizu-in-your-codebase.md +195 -0
  162. package/vendor/skills/orizu/references/primer.md +4 -2
  163. package/vendor/skills/orizu/references/prompt-control-plane.md +33 -29
  164. package/vendor/skills/orizu/references/vocabulary.md +1 -1
@@ -16,7 +16,8 @@
16
16
  * driver; 'claude-agent-sdk': construct the in-process Claude-Agent-SDK
17
17
  * driver — no server to install or spawn), then drive the SINGLE task prompt
18
18
  * (v0: no queue) through `drainHarnessToSink`;
19
- * 5. the terminal event decides the run's final status via the sink's PATCH.
19
+ * 5. checkpoint dirty or local-only Git work without replacing the agent's
20
+ * terminal result, then deliver that original result through the sink.
20
21
  *
21
22
  * SWAPPABILITY (ALI-929 / P3.6): harness SELECTION is the ONLY thing that differs
22
23
  * between the two drivers — `start()` → `runPrompt()` → `drainHarnessToSink()` →
@@ -29,19 +30,22 @@
29
30
  * per-exec fallback — with firewall brokering the real key never reaches here).
30
31
  *
31
32
  * TESTABILITY: `runHostedLoop` takes injected `fetchImpl`, `spawnOpenCode`,
32
- * `installOpenCode`, and `createHarness`, so the whole loop runs in-process
33
- * against a fake broker + fake OpenCode server with no real binary.
33
+ * `installOpenCode`, `createHarness`, and Git execution, so the whole loop can
34
+ * run in-process against fake boundaries; separate tests exercise the real
35
+ * packaged CLI, HTTPS smart-Git transport, and OpenCode process boundary.
34
36
  */
35
37
  import { spawnSync, spawn as nodeSpawn } from 'child_process';
36
38
  import { closeSync, existsSync, openSync, readFileSync } from 'fs';
37
39
  import { tmpdir } from 'os';
38
40
  import { join } from 'path';
39
- import { HOSTED_TASK_PREAMBLE, PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, composeHostedTaskPrompt, parsePrebakedMarker, } from './hosted-runtime-assets.js';
41
+ import { isHarnessTerminalKind } from './hosted-harness.js';
42
+ import { SETUP_HOOK_RELATIVE_PATH, composeHostedTaskPrompt, parsePrebakedMarker, resolveHostedTaskPreamble, resolvePrebakedMarkerPath, } from './hosted-runtime-assets.js';
40
43
  import { harvestWorkspace } from './hosted-harvest.js';
41
44
  import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js';
42
- import { DEFAULT_PROMPT_MAX_DURATION_MS, OPENCODE_PINNED_VERSION, awaitOpenCodeModelResolvable, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
45
+ import { DEFAULT_PROMPT_MAX_DURATION_MS, OPENCODE_PINNED_VERSION, awaitOpenCodeModelResolvable, createOpenCodeHarness, permissionForOrigin, spawnOpenCode, } from './hosted-harness-opencode.js';
43
46
  import { createClaudeAgentHarness } from './hosted-harness-claude.js';
44
47
  import { INJECTED_ENV_VARS_ENV } from './hosted-environment.js';
48
+ import { interceptHostedQuestions } from './hosted-question.js';
45
49
  import { drainHarnessToSink, resumeRunEventSink, RunTerminalError, TerminalDeliveryError, } from './hosted-run-event-sink.js';
46
50
  import { buildOpenCodeSpawnEnv, defaultProbeEgress, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
47
51
  // The lifecycle/orchestration seam moved to `hosted-loop-lifecycle.ts`
@@ -136,16 +140,17 @@ function redactionListFromEnv() {
136
140
  * falls back to a belt read of the `/opt/orizu/prebaked.json` marker (validated
137
141
  * via `parsePrebakedMarker`, so a stray file cannot trigger a skip). Either → true.
138
142
  */
139
- function detectLoopPrebaked(flag) {
140
- if (flag)
141
- return true;
143
+ function hasValidPrebakedMarker(markerPath) {
142
144
  try {
143
- return parsePrebakedMarker(readFileSync(PREBAKED_MARKER_PATH, 'utf8')) !== null;
145
+ return parsePrebakedMarker(readFileSync(markerPath, 'utf8')) !== null;
144
146
  }
145
147
  catch {
146
148
  return false;
147
149
  }
148
150
  }
151
+ function detectLoopPrebaked(flag, markerPath) {
152
+ return flag === true || hasValidPrebakedMarker(markerPath);
153
+ }
149
154
  /** Best-effort global install of the pinned opencode. Non-fatal: a failure is
150
155
  * recorded and the run finishes cleanly when the subsequent connect fails. */
151
156
  function installOpenCodePinned(version) {
@@ -193,6 +198,12 @@ export async function runHostedLoop(opts) {
193
198
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
194
199
  const signal = opts.signal ?? new AbortController().signal;
195
200
  const pinnedVersion = context.opencodePinnedVersion ?? OPENCODE_PINNED_VERSION;
201
+ const diagnose = (message) => {
202
+ try {
203
+ opts.onDiagnostic?.(message);
204
+ }
205
+ catch { /* diagnostics are best-effort */ }
206
+ };
196
207
  // Resolve the bearer per request. Precedence: an explicit provider, then a
197
208
  // fixed string, else read the rotated 0600 bearer file every time (production
198
209
  // + rotation path — a host-side rotation overwrites this file and the sink's
@@ -211,6 +222,7 @@ export async function runHostedLoop(opts) {
211
222
  fetchImpl,
212
223
  now: opts.now,
213
224
  redactSecretsList: [...(opts.redactSecretsList ?? []), ...redactionListFromEnv()],
225
+ onDiagnostic: diagnose,
214
226
  });
215
227
  // G5 startup canary (fail-closed, POSITIVE CONTROL): when the host applied an
216
228
  // enforced egress policy it sets `egressCanaryHost`; probe BOTH the denied host
@@ -220,7 +232,7 @@ export async function runHostedLoop(opts) {
220
232
  // unreachable → network broken / indistinguishable from a block) emits
221
233
  // `egress_allowed` and finishes the run FAILED rather than touch real customer
222
234
  // data. A canary whose own event append fails is likewise fail-closed.
223
- if (context.egressCanaryHost) {
235
+ if (context.egressCanaryHost && !opts.sharedRuntime?.startupComplete) {
224
236
  const probe = opts.probeEgress ?? defaultProbeEgress;
225
237
  const targets = {
226
238
  allowedHost: egressCanaryAllowedHost(context.apiBaseUrl),
@@ -273,7 +285,7 @@ export async function runHostedLoop(opts) {
273
285
  // firewall is live, BEFORE any harness/network work. bootstrap deferred it (for
274
286
  // enforced-egress providers) precisely so its network access could not precede
275
287
  // the canary. Non-fatal, single-writer (this loop sink owns the run now).
276
- if (context.runSetupHook) {
288
+ if (context.runSetupHook && !opts.sharedRuntime?.startupComplete) {
277
289
  try {
278
290
  const runHook = opts.runSetupHook ?? defaultRunSetupHook;
279
291
  const outcome = await runHook({ workspaceDir: context.workspaceDir });
@@ -303,27 +315,38 @@ export async function runHostedLoop(opts) {
303
315
  }
304
316
  }
305
317
  }
318
+ if (opts.sharedRuntime)
319
+ opts.sharedRuntime.startupComplete = true;
306
320
  // End-of-run auto-harvest (ALI-1036): commit + push anything the agent left
307
321
  // uncommitted/unpushed so a run NEVER loses work — on BOTH the success and
308
- // failure paths. Runs at most once (guarded), records exactly one of
309
- // work_persisted / work_none / work_persist_failed, and NEVER throws or changes
310
- // the run's terminal status: a harvest failure is recorded and the run proceeds.
322
+ // failure paths. Runs at most once (guarded), records exactly one outcome with
323
+ // critical-intent delivery, and NEVER throws or changes the run's terminal
324
+ // status. If the event cannot land, safe outcome metadata rides the already-
325
+ // critical terminal PATCH instead.
326
+ const prebakedMarkerPath = resolvePrebakedMarkerPath();
311
327
  let harvested = false;
328
+ let checkpointFinishPatch;
312
329
  const runAutoHarvest = async () => {
313
330
  if (harvested)
314
- return;
331
+ return checkpointFinishPatch;
315
332
  harvested = true;
316
333
  let outcome;
317
334
  try {
318
- outcome = harvestWorkspace({
319
- workspaceDir: context.workspaceDir,
320
- runId: context.runId,
321
- author: context.author,
322
- exec: opts.harvestExec,
323
- // Real harvest only inside a genuine hosted sandbox (prebaked marker
324
- // present) — never on a host/test working tree (review finding #1).
325
- enabled: existsSync(PREBAKED_MARKER_PATH),
326
- });
335
+ const markerValid = hasValidPrebakedMarker(prebakedMarkerPath);
336
+ outcome = !opts.harvestExec && !markerValid
337
+ ? { kind: 'work_persist_failed', error: 'prebaked_marker_invalid' }
338
+ : harvestWorkspace({
339
+ workspaceDir: context.workspaceDir,
340
+ runId: context.runId,
341
+ sessionBranch: context.sessionBranch,
342
+ repositoryRemote: context.repositoryRemote,
343
+ credentialHelper: context.repositoryCredentialHelper,
344
+ author: context.author,
345
+ exec: opts.harvestExec,
346
+ // Real harvest requires a parsed marker from the hosted image;
347
+ // mere path existence must never authorize default Git mutations.
348
+ enabled: markerValid,
349
+ });
327
350
  }
328
351
  catch (error) {
329
352
  outcome = { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) };
@@ -332,29 +355,54 @@ export async function runHostedLoop(opts) {
332
355
  return;
333
356
  try {
334
357
  if (outcome.kind === 'work_persisted') {
335
- await sink.append({ kind: 'work_persisted', payload: { sha: outcome.sha, files: outcome.files } });
358
+ await sink.append({
359
+ kind: 'work_persisted',
360
+ payload: { sha: outcome.sha, ...(outcome.files ? { files: outcome.files } : {}) },
361
+ critical: true,
362
+ });
336
363
  }
337
364
  else if (outcome.kind === 'work_none') {
338
- await sink.append({ kind: 'work_none', payload: {} });
365
+ await sink.append({ kind: 'work_none', payload: {}, critical: true });
366
+ }
367
+ else if (outcome.kind === 'work_not_inspected') {
368
+ await sink.append({ kind: 'work_not_inspected', payload: {}, critical: true });
339
369
  }
340
370
  else {
341
- await sink.append({ kind: 'work_persist_failed', payload: { error: outcome.error } });
371
+ await sink.append({ kind: 'work_persist_failed', payload: { error: outcome.error }, critical: true });
342
372
  }
343
373
  }
344
374
  catch {
345
- // Recording harvest is best-effort — never fail the run because the harvest
346
- // event append failed.
375
+ // Value-free by construction: raw Git/provider errors and credentials must
376
+ // not enter process diagnostics or the terminal summary.
377
+ diagnose('hosted checkpoint event delivery failed');
378
+ const checkpointDelivery = outcome.kind === 'work_persisted'
379
+ ? {
380
+ outcome: 'saved', sha: outcome.sha,
381
+ ...(outcome.files ? { fileCount: outcome.files.length } : {}),
382
+ }
383
+ : outcome.kind === 'work_none'
384
+ ? { outcome: 'no_changes' }
385
+ : outcome.kind === 'work_not_inspected'
386
+ ? { outcome: 'not_inspected' }
387
+ : { outcome: 'unsaved' };
388
+ checkpointFinishPatch = {
389
+ summary: { checkpoint_delivery_failed: checkpointDelivery },
390
+ };
347
391
  }
392
+ return checkpointFinishPatch;
348
393
  };
349
- let spawned = null;
350
- let agentSessionId = null;
351
- let installOk = false;
394
+ let spawned = opts.sharedRuntime?.spawned ?? null;
395
+ let agentSessionId = opts.sharedRuntime?.agentSessionId ?? null;
396
+ let installOk = opts.sharedRuntime?.installOk ?? false;
352
397
  const harnessKind = context.harness ?? 'opencode';
353
398
  try {
354
- // --- Harness PROVISIONING: the ONLY thing that differs between the two
355
- // drivers (ALI-929). Everything after `harness` is constructed is identical.
399
+ // A session runtime provisions and starts its harness exactly once. Later
400
+ // turns reuse this object and therefore the same native conversation.
356
401
  let harness;
357
- if (harnessKind === 'claude-agent-sdk') {
402
+ if (opts.sharedRuntime?.harness) {
403
+ harness = opts.sharedRuntime.harness;
404
+ }
405
+ else if (harnessKind === 'claude-agent-sdk') {
358
406
  // In-process agent loop: no server to install or spawn. Record an
359
407
  // analogous setup artifact so the event stream shape is unchanged.
360
408
  installOk = true;
@@ -371,7 +419,7 @@ export async function runHostedLoop(opts) {
371
419
  // PRE-BAKED (ALI-1017): the pinned `opencode` is already on PATH, so SKIP the
372
420
  // install (npm is blocked under G5 default-deny egress) and record
373
421
  // `opencode_prebaked`. FROM-SCRATCH (local-sim / fallback): install as before.
374
- const prebaked = detectLoopPrebaked(context.prebaked);
422
+ const prebaked = detectLoopPrebaked(context.prebaked, prebakedMarkerPath);
375
423
  if (prebaked) {
376
424
  installOk = true;
377
425
  await sink.append({
@@ -396,11 +444,12 @@ export async function runHostedLoop(opts) {
396
444
  // connection-refused "fetch failed" (ALI-1034).
397
445
  spawned = await spawnImpl({
398
446
  model: context.model,
447
+ permission: permissionForOrigin(context.sessionOrigin),
399
448
  cwd: context.workspaceDir,
400
449
  port: context.opencodePort,
401
450
  env: buildOpenCodeSpawnEnv(context),
402
451
  spawn: nodeChildSpawner,
403
- logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
452
+ logPath: opts.sharedRuntime?.spawnLogPath ?? join(tmpdir(), `opencode-serve-${context.runId}.log`),
404
453
  signal,
405
454
  });
406
455
  if (typeof spawned.readyAfterMs === 'number') {
@@ -451,23 +500,32 @@ export async function runHostedLoop(opts) {
451
500
  harness = (opts.createHarness ??
452
501
  ((baseUrl, options) => createOpenCodeHarness({ baseUrl, promptMaxDurationMs: options?.promptMaxDurationMs })))(spawned.baseUrl, { promptMaxDurationMs });
453
502
  }
454
- const started = await harness.start({
455
- workspaceDir: context.workspaceDir,
456
- model: context.model,
457
- reasoningEffort: context.reasoningEffort,
458
- resumeAgentSessionId: context.resumeAgentSessionId,
459
- });
460
- agentSessionId = started.agentSessionId;
503
+ if (!opts.sharedRuntime?.harness) {
504
+ const started = await harness.start({
505
+ workspaceDir: context.workspaceDir,
506
+ model: context.model,
507
+ reasoningEffort: context.reasoningEffort,
508
+ resumeAgentSessionId: context.resumeAgentSessionId,
509
+ });
510
+ agentSessionId = started.agentSessionId;
511
+ if (opts.sharedRuntime) {
512
+ opts.sharedRuntime.harness = harness;
513
+ opts.sharedRuntime.spawned = spawned;
514
+ opts.sharedRuntime.agentSessionId = agentSessionId;
515
+ opts.sharedRuntime.installOk = installOk;
516
+ }
517
+ }
461
518
  // Prompt scaffolding (ALI-1036): wrap the user task with the standing
462
519
  // headless preamble, keeping the task verbatim beneath a delimiter.
463
520
  const prompt = {
464
521
  runId: context.runId,
465
522
  messageId: context.messageId,
466
- content: composeHostedTaskPrompt(taskPrompt, opts.taskPreamble ?? HOSTED_TASK_PREAMBLE),
523
+ content: composeHostedTaskPrompt(taskPrompt, opts.taskPreamble ?? resolveHostedTaskPreamble(context.sessionOrigin)),
467
524
  author: context.author,
468
525
  };
469
- // Drive the prompt through (a) question auto-handling annotation and (b) the
470
- // idle watchdog (ALI-1037): no harness event for `idleTimeoutMs` aborts the
526
+ // Drive the prompt through question-deny annotation unless hosted-web
527
+ // OpenCode interception owns the outcome, then through the idle watchdog
528
+ // (ALI-1037): no harness event for `idleTimeoutMs` aborts the
471
529
  // prompt and throws `AgentStalledError` → the catch below harvests, then
472
530
  // finishes the run `failed`. The prompt runs under a CHILD AbortController
473
531
  // linked to the loop signal so the watchdog can abort the in-flight prompt
@@ -479,7 +537,95 @@ export async function runHostedLoop(opts) {
479
537
  promptController.abort();
480
538
  else
481
539
  signal.addEventListener('abort', () => promptController.abort(), { once: true });
482
- const annotated = annotateHeadlessQuestions(harness.runPrompt(prompt, promptController.signal));
540
+ // A human interrupt is prompt-scoped: signal the production driver (whose
541
+ // abort path sends OpenCode POST /session/:id/abort) and invoke stop() as a
542
+ // best-effort belt. This callback is deliberately total. All callers share
543
+ // one promise so the question path can await confirmed quiescence. Once this
544
+ // prompt yields its terminal event, a late marker belongs to the terminal/
545
+ // harvest window and must not abort the next queued prompt.
546
+ let hasPromptTerminal = false;
547
+ let quiescencePromise = null;
548
+ const beginQuiescence = () => {
549
+ promptController.abort();
550
+ if (quiescencePromise)
551
+ return quiescencePromise;
552
+ try {
553
+ quiescencePromise = Promise.resolve(harness.stop()).catch(() => {
554
+ diagnose('hosted interrupt stop failed; prompt signal remains authoritative');
555
+ });
556
+ }
557
+ catch {
558
+ diagnose('hosted interrupt stop failed; prompt signal remains authoritative');
559
+ quiescencePromise = Promise.resolve();
560
+ }
561
+ return quiescencePromise;
562
+ };
563
+ const handleInterrupt = () => {
564
+ if (hasPromptTerminal)
565
+ return;
566
+ void beginQuiescence();
567
+ };
568
+ const interruptSignal = opts.interruptSignal;
569
+ if (interruptSignal?.aborted)
570
+ handleInterrupt();
571
+ else
572
+ interruptSignal?.addEventListener('abort', handleInterrupt, { once: true });
573
+ const terminalSummary = { agentSessionId };
574
+ let hasQuestionOutcome = false;
575
+ const promptEvents = harness.runPrompt(prompt, promptController.signal);
576
+ const hasHostedQuestionInterception = harnessKind === 'opencode' && context.sessionOrigin === 'hosted-web';
577
+ const intercepted = hasHostedQuestionInterception
578
+ ? interceptHostedQuestions(promptEvents, {
579
+ onDetected: question => {
580
+ hasQuestionOutcome = true;
581
+ terminalSummary.outcome = 'question_pending';
582
+ terminalSummary.questionId = question.questionId;
583
+ },
584
+ onInvalid: reason => {
585
+ hasQuestionOutcome = true;
586
+ terminalSummary.outcome = 'question_invalid';
587
+ terminalSummary.reason = reason;
588
+ },
589
+ // Async-generator resumption occurs only after drain appended the
590
+ // yielded critical event. Reuse ALI-1760's single abort owner.
591
+ onPersisted: beginQuiescence,
592
+ onInvalidPersisted: beginQuiescence,
593
+ })
594
+ : promptEvents;
595
+ const questionAware = hasHostedQuestionInterception
596
+ ? intercepted
597
+ : annotateHeadlessQuestions(intercepted);
598
+ const interruptAware = (async function* () {
599
+ for await (const event of questionAware) {
600
+ if (isHarnessTerminalKind(event.kind)) {
601
+ hasPromptTerminal = true;
602
+ if (hasQuestionOutcome || (interruptSignal?.aborted && event.kind !== 'error')) {
603
+ yield {
604
+ kind: 'execution_complete',
605
+ messageId: prompt.messageId,
606
+ critical: true,
607
+ payload: { success: false, aborted: true },
608
+ };
609
+ }
610
+ else {
611
+ yield event;
612
+ }
613
+ return;
614
+ }
615
+ yield event;
616
+ }
617
+ // A signal already durable before prompt startup can make a driver end
618
+ // without yielding. It is still an intentional cancellation, not a run
619
+ // failure or a missing-terminal fault.
620
+ if (interruptSignal?.aborted || hasQuestionOutcome) {
621
+ yield {
622
+ kind: 'execution_complete',
623
+ messageId: prompt.messageId,
624
+ critical: true,
625
+ payload: { success: false, aborted: true },
626
+ };
627
+ }
628
+ })();
483
629
  if (idleTimeoutMs <= 0) {
484
630
  // <= 0 explicitly DISABLES the watchdog (e.g. ORIZU_AGENT_IDLE_TIMEOUT_MS=0):
485
631
  // a stalled prompt will hang until the sandbox/prompt hard cap. Record it so
@@ -493,32 +639,42 @@ export async function runHostedLoop(opts) {
493
639
  });
494
640
  }
495
641
  const drivenStream = idleTimeoutMs > 0
496
- ? withIdleWatchdog(annotated, {
642
+ ? withIdleWatchdog(interruptAware, {
497
643
  timeoutMs: idleTimeoutMs,
498
644
  onTimeout: async () => {
499
645
  promptController.abort();
500
646
  await harness.stop();
501
647
  },
502
- // The `question` tool is DENIED headless, so a model stuck retrying it
503
- // emits a stream of synthetic `question_auto_answered` events. Those are
648
+ // Without hosted-web OpenCode interception, a model stuck retrying a
649
+ // denied question emits synthetic `question_auto_answered` events. Those are
504
650
  // NOT progress — excluding them lets a deny/retry loop trip agent_stalled
505
651
  // instead of resetting the idle timer forever (ALI-1069).
506
652
  isProgress: event => event.kind !== 'question_auto_answered',
507
653
  })
508
- : annotated;
509
- const status = await drainHarnessToSink(drivenStream, sink, {
510
- summary: { agentSessionId },
511
- // Auto-harvest runs on EVERY terminal path, before the sink seals.
512
- beforeFinish: runAutoHarvest,
513
- });
514
- await harness.shutdown();
654
+ : interruptAware;
655
+ let status;
656
+ try {
657
+ status = await drainHarnessToSink(drivenStream, sink, {
658
+ summary: terminalSummary,
659
+ // Auto-harvest runs on EVERY terminal path, before the sink seals.
660
+ beforeFinish: runAutoHarvest,
661
+ });
662
+ }
663
+ finally {
664
+ interruptSignal?.removeEventListener('abort', handleInterrupt);
665
+ }
666
+ if (!opts.sharedRuntime)
667
+ await harness.shutdown();
668
+ if (opts.sharedRuntime)
669
+ opts.sharedRuntime.agentSessionId = agentSessionId;
515
670
  return { status, agentSessionId, installOk, error: null };
516
671
  }
517
672
  catch (error) {
518
673
  const message = error instanceof Error ? error.message : String(error);
519
674
  // Harvest FIRST on every abnormal path (partial work is valuable, incl. an
520
675
  // idle-watchdog abort) — before any terminal write seals the sink.
521
- await runAutoHarvest();
676
+ const checkpointPatch = await runAutoHarvest();
677
+ const checkpointSummary = checkpointPatch?.summary ?? {};
522
678
  // ALI-1065 finding 2: an undeliverable TERMINAL transition must NEVER be
523
679
  // re-recorded as 'failed' — the run's outcome was already decided; only its
524
680
  // DELIVERY failed. Retry the delivery with the ORIGINAL status (spaced past
@@ -569,7 +725,7 @@ export async function runHostedLoop(opts) {
569
725
  // no-ops when the drain already sealed the sink.
570
726
  if (!sink.sealed) {
571
727
  try {
572
- await sink.finish('failed', { summary: { error: message } });
728
+ await sink.finish('failed', { summary: { error: message, ...checkpointSummary } });
573
729
  }
574
730
  catch {
575
731
  // best-effort — the terminal write itself may be impossible (bearer gone)
@@ -578,7 +734,7 @@ export async function runHostedLoop(opts) {
578
734
  return { status: 'failed', agentSessionId, installOk, error: message };
579
735
  }
580
736
  finally {
581
- if (spawned) {
737
+ if (spawned && (!opts.sharedRuntime || opts.sharedRuntime.spawned !== spawned)) {
582
738
  try {
583
739
  spawned.stop();
584
740
  }
@@ -588,6 +744,90 @@ export async function runHostedLoop(opts) {
588
744
  }
589
745
  }
590
746
  }
747
+ export async function startHostedLoopSession(context) {
748
+ return {
749
+ context,
750
+ runtime: {
751
+ harness: null,
752
+ spawned: null,
753
+ agentSessionId: null,
754
+ installOk: false,
755
+ startupComplete: false,
756
+ spawnLogPath: join(context.sessionDir, `opencode-serve-${context.sessionId}.log`),
757
+ closed: false,
758
+ },
759
+ };
760
+ }
761
+ export async function runHostedLoopTurn(session, turn, options) {
762
+ if (session.runtime.closed)
763
+ throw new Error('hosted loop session is closed');
764
+ return runHostedLoop({
765
+ ...options,
766
+ context: {
767
+ ...session.context,
768
+ runId: turn.runId,
769
+ taskFile: turn.taskFile,
770
+ messageId: turn.messageId,
771
+ bearerFile: turn.bearerFile,
772
+ resumeAgentSessionId: session.runtime.agentSessionId ?? session.context.resumeAgentSessionId,
773
+ },
774
+ taskPrompt: options.taskPrompt,
775
+ sharedRuntime: session.runtime,
776
+ });
777
+ }
778
+ export async function closeHostedLoopSession(session) {
779
+ if (session.runtime.closed)
780
+ return;
781
+ session.runtime.closed = true;
782
+ try {
783
+ await session.runtime.harness?.shutdown();
784
+ }
785
+ finally {
786
+ session.runtime.spawned?.stop();
787
+ session.runtime.harness = null;
788
+ session.runtime.spawned = null;
789
+ }
790
+ }
791
+ const REQUIRED_CONTEXT_STRINGS = [
792
+ 'apiBaseUrl', 'runId', 'bearerFile', 'taskFile', 'workspaceDir',
793
+ 'sessionBranch', 'repositoryRemote', 'model', 'messageId',
794
+ ];
795
+ const OPTIONAL_CONTEXT_STRINGS = [
796
+ 'reasoningEffort', 'opencodePinnedVersion', 'anthropicDummyKey',
797
+ 'resumeAgentSessionId', 'egressCanaryHost', 'repositoryCredentialHelper',
798
+ ];
799
+ function isNonemptyString(value) {
800
+ return typeof value === 'string' && value.length > 0;
801
+ }
802
+ function validateHostedLoopContext(value) {
803
+ if (!value || typeof value !== 'object' || Array.isArray(value))
804
+ return { context: null, fields: ['root'] };
805
+ const record = value;
806
+ const fields = REQUIRED_CONTEXT_STRINGS.filter(name => !isNonemptyString(record[name]));
807
+ const branch = record.sessionBranch;
808
+ if (typeof branch === 'string' && (branch.length > 255 || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(branch) ||
809
+ branch.includes('..') || branch.includes('//') || branch.endsWith('/') || branch.includes('@{')))
810
+ fields.push('sessionBranch');
811
+ const author = record.author;
812
+ if (!author || typeof author !== 'object' || Array.isArray(author) ||
813
+ !isNonemptyString(author.name) || !isNonemptyString(author.email))
814
+ fields.push('author');
815
+ if (record.harness !== undefined && record.harness !== 'opencode' && record.harness !== 'claude-agent-sdk')
816
+ fields.push('harness');
817
+ if (record.sessionOrigin !== undefined && record.sessionOrigin !== 'hosted-web' && record.sessionOrigin !== 'cli')
818
+ fields.push('sessionOrigin');
819
+ if (record.sandboxBudgetMs !== undefined && (typeof record.sandboxBudgetMs !== 'number' || !Number.isFinite(record.sandboxBudgetMs) || record.sandboxBudgetMs <= 0))
820
+ fields.push('sandboxBudgetMs');
821
+ if (record.opencodePort !== undefined && (typeof record.opencodePort !== 'number' || !Number.isInteger(record.opencodePort) || record.opencodePort <= 0 || record.opencodePort > 65_535))
822
+ fields.push('opencodePort');
823
+ for (const name of OPTIONAL_CONTEXT_STRINGS)
824
+ if (record[name] !== undefined && typeof record[name] !== 'string')
825
+ fields.push(name);
826
+ for (const name of ['prebaked', 'runSetupHook'])
827
+ if (record[name] !== undefined && typeof record[name] !== 'boolean')
828
+ fields.push(name);
829
+ return { context: fields.length === 0 ? value : null, fields: [...new Set(fields)] };
830
+ }
591
831
  function argValue(args, flag) {
592
832
  const index = args.indexOf(flag);
593
833
  if (index === -1 || index + 1 >= args.length)
@@ -606,19 +846,25 @@ export async function hostedLoopCommand(args, io) {
606
846
  io.printErr?.('Usage: orizu internal hosted-loop --context <path>');
607
847
  return 1;
608
848
  }
609
- let context;
849
+ let rawContext;
610
850
  try {
611
- context = JSON.parse(readFileSync(contextPath, 'utf8'));
851
+ rawContext = JSON.parse(readFileSync(contextPath, 'utf8'));
612
852
  }
613
853
  catch (error) {
614
854
  io.printErr?.(`unreadable loop context: ${error instanceof Error ? error.message : String(error)}`);
615
855
  return 1;
616
856
  }
857
+ const parsedContext = validateHostedLoopContext(rawContext);
858
+ if (!parsedContext.context) {
859
+ io.printErr?.(`invalid loop context: ${parsedContext.fields.join(', ')}`);
860
+ return 1;
861
+ }
862
+ const context = parsedContext.context;
617
863
  // Read the bearer per request (via the provider) so a host-side rotation that
618
864
  // overwrites the 0600 file is picked up without restarting the loop.
619
865
  const bearerProvider = () => readFileSync(context.bearerFile, 'utf8').trim();
620
866
  const taskPrompt = readFileSync(context.taskFile, 'utf8');
621
- const result = await runHostedLoop({ context, bearerProvider, taskPrompt });
867
+ const result = await runHostedLoop({ context, bearerProvider, taskPrompt, onDiagnostic: io.printErr });
622
868
  io.print(io.json
623
869
  ? JSON.stringify({ status: result.status, agentSessionId: result.agentSessionId, error: result.error })
624
870
  : `hosted-loop finished: ${result.status}${result.error ? ` (${result.error})` : ''}`);
@@ -0,0 +1,7 @@
1
+ export declare const HOSTED_QUESTION_LIMITS: Readonly<{
2
+ readonly questionMax: 2000;
3
+ readonly optionLabelMax: 200;
4
+ readonly optionDescriptionMax: 500;
5
+ readonly optionCountMax: 8;
6
+ }>;
7
+ //# sourceMappingURL=hosted-question-limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosted-question-limits.d.ts","sourceRoot":"","sources":["../src/hosted-question-limits.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB;;;;;EAKxB,CAAA"}
@@ -0,0 +1,6 @@
1
+ export const HOSTED_QUESTION_LIMITS = Object.freeze({
2
+ questionMax: 2_000,
3
+ optionLabelMax: 200,
4
+ optionDescriptionMax: 500,
5
+ optionCountMax: 8,
6
+ });
@@ -0,0 +1,27 @@
1
+ import type { HarnessEvent } from './hosted-harness.js';
2
+ export { HOSTED_QUESTION_LIMITS } from './hosted-question-limits.js';
3
+ export interface HostedPendingQuestionOption {
4
+ label: string;
5
+ description: string | null;
6
+ }
7
+ export interface HostedPendingQuestion {
8
+ questionId: string;
9
+ question: string;
10
+ options: HostedPendingQuestionOption[];
11
+ }
12
+ export declare const INVALID_HOSTED_QUESTION_REASON = "expected_exactly_one_bounded_question";
13
+ export declare class InvalidHostedQuestionError extends Error {
14
+ readonly reason = "expected_exactly_one_bounded_question";
15
+ constructor();
16
+ }
17
+ export declare function parseHostedQuestion(args: unknown, createQuestionId?: () => string): HostedPendingQuestion;
18
+ export interface InterceptHostedQuestionOptions {
19
+ createQuestionId?: () => string;
20
+ onDetected(question: HostedPendingQuestion): void;
21
+ onPersisted(question: HostedPendingQuestion): Promise<void>;
22
+ onInvalid?(reason: string): void;
23
+ onInvalidPersisted?(reason: string): Promise<void>;
24
+ }
25
+ export declare function interceptHostedQuestions(source: AsyncIterable<HarnessEvent>, options: InterceptHostedQuestionOptions): AsyncGenerator<HarnessEvent>;
26
+ export declare function composeHostedAnswerPrompt(question: Pick<HostedPendingQuestion, 'questionId' | 'question'>, answerText: string): string;
27
+ //# sourceMappingURL=hosted-question.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosted-question.d.ts","sourceRoot":"","sources":["../src/hosted-question.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAIvD,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;AAQpE,MAAM,WAAW,2BAA2B;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,2BAA2B,EAAE,CAAA;CACvC;AAED,eAAO,MAAM,8BAA8B,0CAA0C,CAAA;AAErF,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,QAAQ,CAAC,MAAM,2CAAiC;;CAMjD;AAsBD,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,gBAAgB,GAAE,MAAM,MAAmB,GAC1C,qBAAqB,CAgBvB;AAED,MAAM,WAAW,8BAA8B;IAC7C,gBAAgB,CAAC,EAAE,MAAM,MAAM,CAAA;IAC/B,UAAU,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAA;IACjD,WAAW,CAAC,QAAQ,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3D,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,kBAAkB,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnD;AAED,wBAAuB,wBAAwB,CAC7C,MAAM,EAAE,aAAa,CAAC,YAAY,CAAC,EACnC,OAAO,EAAE,8BAA8B,GACtC,cAAc,CAAC,YAAY,CAAC,CAwC9B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,IAAI,CAAC,qBAAqB,EAAE,YAAY,GAAG,UAAU,CAAC,EAChE,UAAU,EAAE,MAAM,GACjB,MAAM,CAWR"}