orizu 0.5.39 → 0.5.41

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 (117) hide show
  1. package/README.md +77 -0
  2. package/dist/dataset-clone-workflow.d.ts +11 -0
  3. package/dist/dataset-clone-workflow.d.ts.map +1 -0
  4. package/dist/dataset-clone-workflow.js +100 -0
  5. package/dist/dataset-commands.d.ts +3 -1
  6. package/dist/dataset-commands.d.ts.map +1 -1
  7. package/dist/dataset-commands.js +107 -91
  8. package/dist/dataset-delete-workflow.d.ts +4 -0
  9. package/dist/dataset-delete-workflow.d.ts.map +1 -0
  10. package/dist/dataset-delete-workflow.js +75 -0
  11. package/dist/dataset-lock-workflow.d.ts +12 -0
  12. package/dist/dataset-lock-workflow.d.ts.map +1 -0
  13. package/dist/dataset-lock-workflow.js +76 -0
  14. package/dist/dataset-readme-workflow.d.ts +13 -0
  15. package/dist/dataset-readme-workflow.d.ts.map +1 -0
  16. package/dist/dataset-readme-workflow.js +82 -0
  17. package/dist/dataset-row-workflow.d.ts +14 -0
  18. package/dist/dataset-row-workflow.d.ts.map +1 -0
  19. package/dist/dataset-row-workflow.js +202 -0
  20. package/dist/dataset-split-reads.d.ts +28 -0
  21. package/dist/dataset-split-reads.d.ts.map +1 -0
  22. package/dist/dataset-split-reads.js +47 -0
  23. package/dist/dataset-transfer-upload.d.ts +2 -6
  24. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  25. package/dist/dataset-transfer-upload.js +9 -2
  26. package/dist/dataset-upload-snapshot.d.ts.map +1 -1
  27. package/dist/dataset-upload-snapshot.js +7 -1
  28. package/dist/dataset-version-workflow.d.ts +24 -0
  29. package/dist/dataset-version-workflow.d.ts.map +1 -0
  30. package/dist/dataset-version-workflow.js +99 -0
  31. package/dist/feedback-cli.js +3 -3
  32. package/dist/help.d.ts.map +1 -1
  33. package/dist/help.js +29 -3
  34. package/dist/hosted-boot.d.ts +1 -0
  35. package/dist/hosted-boot.d.ts.map +1 -1
  36. package/dist/hosted-boot.js +17 -0
  37. package/dist/hosted-harness-opencode.d.ts +5 -6
  38. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  39. package/dist/hosted-harness-opencode.js +73 -59
  40. package/dist/hosted-loop-lifecycle.d.ts +6 -11
  41. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  42. package/dist/hosted-loop-lifecycle.js +6 -11
  43. package/dist/hosted-loop.d.ts +9 -0
  44. package/dist/hosted-loop.d.ts.map +1 -1
  45. package/dist/hosted-loop.js +19 -10
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +11 -3
  48. package/dist/instruction-delivery.d.ts +14 -0
  49. package/dist/instruction-delivery.d.ts.map +1 -0
  50. package/dist/instruction-delivery.js +103 -0
  51. package/dist/instruction-set-sync/index.d.ts +17 -0
  52. package/dist/instruction-set-sync/index.d.ts.map +1 -1
  53. package/dist/instruction-set-sync/index.js +44 -17
  54. package/dist/instruction-set-update/index.d.ts +2 -2
  55. package/dist/instruction-set-update/index.d.ts.map +1 -1
  56. package/dist/instruction-set-update/index.js +2 -2
  57. package/dist/instruction-sets-cli.d.ts.map +1 -1
  58. package/dist/instruction-sets-cli.js +19 -27
  59. package/dist/json-response.d.ts +1 -0
  60. package/dist/json-response.d.ts.map +1 -1
  61. package/dist/json-response.js +7 -0
  62. package/dist/last-error-record.d.ts +4 -2
  63. package/dist/last-error-record.d.ts.map +1 -1
  64. package/dist/last-error-record.js +10 -68
  65. package/dist/last-error-workflow.d.ts +4 -0
  66. package/dist/last-error-workflow.d.ts.map +1 -0
  67. package/dist/last-error-workflow.js +78 -0
  68. package/dist/preview-runtime.d.ts +2 -2
  69. package/dist/preview-runtime.d.ts.map +1 -1
  70. package/dist/preview-runtime.js +37 -7
  71. package/dist/task-commands.d.ts.map +1 -1
  72. package/dist/task-commands.js +9 -16
  73. package/dist/task-export-workflow.d.ts +4 -0
  74. package/dist/task-export-workflow.d.ts.map +1 -0
  75. package/dist/task-export-workflow.js +46 -0
  76. package/dist/task-report-workflow.d.ts +47 -0
  77. package/dist/task-report-workflow.d.ts.map +1 -0
  78. package/dist/task-report-workflow.js +160 -0
  79. package/dist/workbench-cli.js +1 -1
  80. package/package.json +1 -1
  81. package/src/dataset-clone-workflow.ts +100 -0
  82. package/src/dataset-commands.ts +95 -142
  83. package/src/dataset-delete-workflow.ts +72 -0
  84. package/src/dataset-lock-workflow.ts +74 -0
  85. package/src/dataset-readme-workflow.ts +80 -0
  86. package/src/dataset-row-workflow.ts +248 -0
  87. package/src/dataset-split-reads.ts +43 -0
  88. package/src/dataset-transfer-upload.ts +12 -5
  89. package/src/dataset-upload-snapshot.ts +8 -1
  90. package/src/dataset-version-workflow.ts +105 -0
  91. package/src/feedback-cli.ts +4 -4
  92. package/src/help.ts +29 -3
  93. package/src/hosted-boot.ts +16 -1
  94. package/src/hosted-harness-opencode.ts +74 -72
  95. package/src/hosted-loop-lifecycle.ts +6 -11
  96. package/src/hosted-loop.ts +20 -10
  97. package/src/hosted-model-cache.d.mts +2 -0
  98. package/src/hosted-model-cache.mjs +53 -0
  99. package/src/index.ts +12 -2
  100. package/src/instruction-delivery.ts +123 -0
  101. package/src/instruction-set-sync/index.ts +61 -14
  102. package/src/instruction-set-update/index.ts +4 -3
  103. package/src/instruction-sets-cli.ts +19 -27
  104. package/src/json-response.ts +8 -0
  105. package/src/last-error-record.ts +11 -85
  106. package/src/last-error-workflow.ts +121 -0
  107. package/src/preview-runtime.ts +40 -10
  108. package/src/task-commands.ts +9 -43
  109. package/src/task-export-workflow.ts +56 -0
  110. package/src/task-report-workflow.ts +179 -0
  111. package/src/workbench-cli.ts +1 -1
  112. package/vendor/skills/orizu/references/cli-reference.md +34 -0
  113. package/vendor/skills/orizu/references/dataset-design.md +1 -1
  114. package/dist/dataset-edit-rows.d.ts +0 -10
  115. package/dist/dataset-edit-rows.d.ts.map +0 -1
  116. package/dist/dataset-edit-rows.js +0 -90
  117. package/src/dataset-edit-rows.ts +0 -124
@@ -150,6 +150,7 @@ export function deriveBootStatusUrl(agentTokenUrl: string): string {
150
150
  * secret defensively before sending. */
151
151
  const MAX_BOOT_REASON_CHARS = 800
152
152
  const TURN_STATUS_ATTEMPT_TIMEOUT_MS = 10_000
153
+ export const HOSTED_TURN_PROGRESS_REPORT_MS = 5 * 60_000
153
154
 
154
155
  function resolveTestPositiveInteger(
155
156
  processEnv: Record<string, string | undefined>,
@@ -241,7 +242,7 @@ async function postRequiredIdleReady(opts: {
241
242
  async function postTurnStatus(opts: {
242
243
  bootStatusUrl: string
243
244
  bootSecret: string
244
- status: 'turn_started' | 'turn_completed' | 'turn_failed'
245
+ status: 'turn_started' | 'turn_progress' | 'turn_completed' | 'turn_failed'
245
246
  runId: string
246
247
  reason?: string | null
247
248
  fetchImpl: BootFetch
@@ -1283,6 +1284,7 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
1283
1284
  minimumDelayMs: bearerRotationMinimumDelayMs,
1284
1285
  })
1285
1286
  const promptInterruptController = new AbortController()
1287
+ let nextProgressAt = now()
1286
1288
  const interruptPollController = new AbortController()
1287
1289
  const interruptPoll = pollHostedSessionDuringTurn({
1288
1290
  baseUrl: env.baseUrl,
@@ -1312,6 +1314,19 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
1312
1314
  bearerProvider: () => readFile(bearerFileAbs).trim(),
1313
1315
  interruptSignal: promptInterruptController.signal,
1314
1316
  onDiagnostic: log,
1317
+ onProgress: async () => {
1318
+ if (now() < nextProgressAt) return
1319
+ nextProgressAt = now() + HOSTED_TURN_PROGRESS_REPORT_MS
1320
+ try {
1321
+ await postTurnStatus({
1322
+ bootStatusUrl: env.bootStatusUrl, bootSecret: env.bootSecret,
1323
+ status: 'turn_progress', runId: current.runId, fetchImpl, sleep,
1324
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
1325
+ })
1326
+ } catch {
1327
+ log('turn progress acknowledgement unavailable')
1328
+ }
1329
+ },
1315
1330
  }
1316
1331
  )
1317
1332
  } finally {
@@ -51,8 +51,10 @@
51
51
 
52
52
  import { readFileSync } from 'fs'
53
53
 
54
+ import { refreshHostedModels } from '../src/hosted-model-cache.mjs'
55
+
54
56
  import { HOSTED_AGENT_NAME, HOSTED_AGENT_PROMPT } from './hosted-agent-prompt.js'
55
- import { findProvider, parseModelIdentity } from './provider-registry.js'
57
+ import { parseModelIdentity } from './provider-registry.js'
56
58
 
57
59
  import { HARNESS_NAMES } from './hosted-harness.js'
58
60
  import type {
@@ -64,13 +66,13 @@ import type {
64
66
  } from './hosted-harness.js'
65
67
 
66
68
  // OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
67
- // AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
68
- // undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
69
- // `session.error`, `session.updated`, ...). Versions newer than 1.14.41 broke
70
- // that stream upstream, so the pin is load-bearing and must be re-verified
71
- // before any bump. The AgentHarness seam is the mitigation of last resort: swap
72
- // to a Claude-Agent-SDK loop (ALI-929) rather than chase OpenCode's SSE.
73
- export const OPENCODE_PINNED_VERSION = '1.14.41'
69
+ // The event model rides OpenCode's undocumented `/event` SSE part schema
70
+ // (`message.part.updated`, `session.idle`, `session.error`, `session.updated`,
71
+ // ...), so every bump must pass test/cli/hosted-opencode-contract-real.test.ts
72
+ // against the real binary (the path-gated `opencode-real` CI job runs it when
73
+ // this pin changes). 1.14.41 → 1.18.35 was measured and checked that way
74
+ // (ORI-2523). The AgentHarness seam remains the mitigation of last resort.
75
+ export const OPENCODE_PINNED_VERSION = '1.18.35'
74
76
 
75
77
  const DEFAULT_OPENCODE_PORT = 4096
76
78
  const DEFAULT_SSE_INACTIVITY_MS = 120_000
@@ -152,17 +154,6 @@ export function ascendingId(prefix: 'ses' | 'msg' | 'prt', nowMs: number = Date.
152
154
  return `${prefix}_${hex}${randomBase62(14)}`
153
155
  }
154
156
 
155
- // -- Anthropic / OpenAI reasoning-effort mapping (port of bridge.py) ---------
156
- const ANTHROPIC_THINKING_BUDGETS: Record<string, number> = { high: 16_000, max: 31_999 }
157
- const ANTHROPIC_ADAPTIVE_THINKING_MODELS = new Set([
158
- 'claude-fable-5',
159
- 'claude-opus-4-6',
160
- 'claude-opus-4-7',
161
- 'claude-opus-4-8',
162
- 'claude-sonnet-4-6',
163
- ])
164
- const ANTHROPIC_ADAPTIVE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
165
-
166
157
  /**
167
158
  * Split a possibly provider-qualified model string into provider + model id,
168
159
  * through THE registry parse (`parseModelIdentity`, ORI-2031): the provider is
@@ -201,34 +192,13 @@ export function buildPromptRequestBody(
201
192
  // Shared parse (see qualifyHostedModel): keeps the prompt path and the
202
193
  // ALI-1086 pre-prompt validation in lockstep by construction.
203
194
  const { providerId, modelId } = qualifyHostedModel(model)
204
- const modelSpec: Record<string, unknown> = { providerID: providerId, modelID: modelId }
205
-
206
- // Reasoning options are a property of the WIRE PROTOCOL, not of a provider
207
- // name (ORI-2031 row 7). `openai-chat` gets no options at all — identical to
208
- // today's fall-through for any provider the driver does not recognise, and
209
- // honest: Orizu has never measured chat-completions reasoning fields
210
- // (ORI-2032). A bare model id still resolves through qualifyHostedModel's
211
- // 'anthropic' default, so its options are unchanged.
212
- const protocol = findProvider(providerId)?.protocol ?? null
213
- if (reasoningEffort) {
214
- if (protocol === 'anthropic-messages') {
215
- if (ANTHROPIC_ADAPTIVE_THINKING_MODELS.has(modelId)) {
216
- const options: Record<string, unknown> = { thinking: { type: 'adaptive' } }
217
- if (ANTHROPIC_ADAPTIVE_EFFORTS.has(reasoningEffort)) {
218
- options.outputConfig = { effort: reasoningEffort }
219
- }
220
- modelSpec.options = options
221
- } else {
222
- const budget = ANTHROPIC_THINKING_BUDGETS[reasoningEffort]
223
- if (budget !== undefined) {
224
- modelSpec.options = { thinking: { type: 'enabled', budgetTokens: budget } }
225
- }
226
- }
227
- } else if (protocol === 'openai-responses') {
228
- modelSpec.options = { reasoningEffort, reasoningSummary: 'auto' }
229
- }
230
- }
231
- body.model = modelSpec
195
+ body.model = { providerID: providerId, modelID: modelId }
196
+ // Reasoning effort travels as the top-level `variant` (ORI-2526). OpenCode's
197
+ // prompt body accepts only providerID/modelID inside `model` and silently
198
+ // dropped the options we used to send there. Each model's variants map the
199
+ // effort onto its own request fields; an effort the model has no variant for
200
+ // is ignored by OpenCode, so no per-provider mapping lives here.
201
+ if (reasoningEffort) body.variant = reasoningEffort
232
202
  return body
233
203
  }
234
204
 
@@ -571,6 +541,8 @@ export function createOpenCodeHarness(options: OpenCodeHarnessOptions): AgentHar
571
541
  const userMessageIds = new Set<string>([opencodeMessageId])
572
542
  const pendingParts = new Map<string, Array<{ part: Record<string, unknown>; delta: unknown }>>()
573
543
  const emittedToolStates = new Set<string>()
544
+ const partTypes = new Map<string, string>()
545
+ const untypedDeltas = new Map<string, string[]>()
574
546
 
575
547
  const handlePart = (part: Record<string, unknown>, delta: unknown): HarnessEvent[] => {
576
548
  const partType = typeof part.type === 'string' ? part.type : ''
@@ -779,16 +751,50 @@ export function createOpenCodeHarness(options: OpenCodeHarnessOptions): AgentHar
779
751
  }
780
752
  }
781
753
  }
782
- } else if (eventType === 'message.part.updated') {
783
- const part = asRecord(props.part)
784
- const delta = props.delta
785
- const ocMsgId = typeof part.messageID === 'string' ? part.messageID : ''
786
- if (allowedAssistantMsgIds.has(ocMsgId)) {
787
- for (const ev of handlePart(part, delta)) yield ev
788
- } else if (ocMsgId) {
789
- const list = pendingParts.get(ocMsgId) ?? []
790
- list.push({ part, delta })
791
- pendingParts.set(ocMsgId, list)
754
+ } else if (eventType === 'message.part.updated' || eventType === 'message.part.delta') {
755
+ // Word-by-word text arrives as `message.part.delta` (ORI-2525), which
756
+ // names the part but not its type. Reasoning parts send deltas too,
757
+ // so a delta streams only once its part is known to be text. A delta
758
+ // can overtake its part's announcement (opencode #26924); it waits
759
+ // here until the announcement says what the part is.
760
+ const updates: Array<{ part: Record<string, unknown>; delta: unknown }> = []
761
+ if (eventType === 'message.part.updated') {
762
+ const part = asRecord(props.part)
763
+ const partId = typeof part.id === 'string' ? part.id : ''
764
+ updates.push({ part, delta: props.delta })
765
+ if (partId && typeof part.type === 'string') partTypes.set(partId, part.type)
766
+ const early = untypedDeltas.get(partId)
767
+ if (early) {
768
+ untypedDeltas.delete(partId)
769
+ const announced = typeof part.text === 'string' ? part.text : ''
770
+ // Replay unless the announced text already includes them.
771
+ if (part.type === 'text' && !announced.startsWith(early.join(''))) {
772
+ for (const delta of early) {
773
+ updates.push({ part: { type: 'text', id: partId, messageID: part.messageID }, delta })
774
+ }
775
+ }
776
+ }
777
+ } else {
778
+ const partId = typeof props.partID === 'string' ? props.partID : ''
779
+ const delta = props.delta
780
+ if (props.field !== 'text' || !partId || typeof delta !== 'string') continue
781
+ const partType = partTypes.get(partId)
782
+ if (partType === undefined) {
783
+ untypedDeltas.set(partId, [...(untypedDeltas.get(partId) ?? []), delta])
784
+ continue
785
+ }
786
+ if (partType !== 'text') continue
787
+ updates.push({ part: { type: 'text', id: partId, messageID: props.messageID }, delta })
788
+ }
789
+ for (const { part, delta } of updates) {
790
+ const ocMsgId = typeof part.messageID === 'string' ? part.messageID : ''
791
+ if (allowedAssistantMsgIds.has(ocMsgId)) {
792
+ for (const ev of handlePart(part, delta)) yield ev
793
+ } else if (ocMsgId) {
794
+ const list = pendingParts.get(ocMsgId) ?? []
795
+ list.push({ part, delta })
796
+ pendingParts.set(ocMsgId, list)
797
+ }
792
798
  }
793
799
  } else if (eventType === 'session.compacted') {
794
800
  // Compaction re-chains messages so subsequent assistant parentIDs no
@@ -910,16 +916,9 @@ function errorEvent(messageId: string, error: unknown): HarnessEvent {
910
916
 
911
917
  // -- Pre-prompt model validation (ALI-1086) -----------------------------------
912
918
  //
913
- // The pinned OpenCode (see OPENCODE_PINNED_VERSION) resolves model ids against
914
- // a catalog that is its BUNDLED snapshot merged with a runtime fetch of
915
- // https://models.dev/api.json (verified empirically on ALI-1086: fetched at
916
- // boot when reachable, cached to <cache>/opencode/models.json, refreshed when
917
- // stale). When models.dev is unreachable the bundled snapshot is stale
918
- // (predates claude-opus-4-8) and the FIRST prompt dies inside the SSE stream
919
- // with an opaque `run_failed: Model not found`. These helpers let the hosted
920
- // loop ask the RUNNING `opencode serve` — `GET /config/providers` is the
921
- // runtime's actual resolvable catalog — whether the requested model resolves,
922
- // BEFORE the prompt is posted, and fail fast naming the alternatives.
919
+ // Images bake a validated catalog. Orizu refreshes it before OpenCode's first
920
+ // read and disables its unchecked background writer (ORI-2513). This validation
921
+ // asks the running server whether the selected model resolves before prompting.
923
922
 
924
923
  /** provider id → model ids the RUNNING opencode instance can resolve. */
925
924
  export type OpenCodeModelCatalog = ReadonlyMap<string, readonly string[]>
@@ -1006,10 +1005,9 @@ export interface AwaitModelResolvableOptions {
1006
1005
  model: string
1007
1006
  fetchImpl?: HarnessFetch
1008
1007
  /**
1009
- * Total budget to wait for the model to become resolvable. OpenCode's
1010
- * models.dev refresh runs in the BACKGROUND at boot, so the catalog can be
1011
- * bundled-only for the first seconds after the server answers HTTP; polling
1012
- * bridges that window instead of failing a run the refresh would have fixed.
1008
+ * Budget for catalog readiness and transient server reads. Hosted startup
1009
+ * finishes its validated refresh before spawn; this polling remains a
1010
+ * compatibility guard for other callers and startup endpoint delays.
1013
1011
  */
1014
1012
  timeoutMs?: number
1015
1013
  pollMs?: number
@@ -1022,7 +1020,7 @@ const MODEL_RESOLVE_POLL_MS = 2_000
1022
1020
 
1023
1021
  /**
1024
1022
  * Decide whether the requested model is resolvable by the RUNNING opencode,
1025
- * polling briefly so the boot-time models.dev refresh has time to land.
1023
+ * polling briefly for catalog readiness or transient server failures.
1026
1024
  * Fail-open on validator infrastructure, with a DELIBERATE asymmetry on null
1027
1025
  * catalog reads:
1028
1026
  * - null BEFORE any successful read → `skipped` immediately (dead/absent
@@ -1207,7 +1205,11 @@ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<Spawned
1207
1205
  const env: Record<string, string> = {
1208
1206
  OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
1209
1207
  ...(opts.env ?? {}),
1208
+ OPENCODE_DISABLE_MODELS_FETCH: '1',
1210
1209
  }
1210
+ const model = qualifyHostedModel(opts.model)
1211
+ env.OPENCODE_MODELS_PATH = await refreshHostedModels({ env: { ...process.env, ...env }, required: [`${model.providerId}/${model.modelId}`], fetchImpl: opts.fetchImpl, signal: opts.signal })
1212
+ opts.signal?.throwIfAborted()
1211
1213
  const cmd = ['opencode', 'serve', '--port', String(port)]
1212
1214
  const spawnImpl =
1213
1215
  opts.spawn ??
@@ -32,17 +32,12 @@ import type { HostedSessionOrigin } from './hosted-runtime-assets.js'
32
32
  * re-inline it.
33
33
  *
34
34
  * PIN CONSTRAINT: the hosted OpenCode runtime is HARD-PINNED to
35
- * `opencode-ai@1.14.41` (`OPENCODE_PINNED_VERSION`, SSE-fragile — do not bump)
36
- * whose BUNDLED model catalog predates this model (newest bundled anthropic
37
- * opus: claude-opus-4-7). Verified empirically on ALI-1086: 1.14.41 DOES fetch
38
- * `https://models.dev/api.json` at boot when reachable (cached to
39
- * `~/.cache/opencode/models.json`, refreshed when stale, disabled only by
40
- * `OPENCODE_DISABLE_MODELS_FETCH`), so with models.dev on the sandbox egress
41
- * allowlist (#1392, `ORIZU_HOSTED_EGRESS_ALLOWLIST`) this id resolves in new
42
- * sandboxes. If the catalog fetch is blocked/stale anyway, the loop's
43
- * pre-prompt validation (`awaitOpenCodeModelResolvable`) fails the run fast,
44
- * naming the resolvable alternatives. The durable fix for the stale bundled
45
- * catalog is the ALI-929 harness swap.
35
+ * `OPENCODE_PINNED_VERSION` (opencode-ai@1.18.35, whose bundled catalog
36
+ * includes this model; bumps are checked by the real-binary contract test,
37
+ * ORI-2523). Images also bake a validated models.dev
38
+ * list into the runtime user's cache. Orizu refreshes that cache before spawning
39
+ * OpenCode, preserving baked models on failure; the native unchecked writer is
40
+ * disabled (ORI-2513). Pre-prompt validation still checks the running server.
46
41
  */
47
42
  export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
48
43
 
@@ -231,6 +231,8 @@ export interface RunHostedLoopOptions {
231
231
  /** Value-free local diagnostics shared with the event sink. Production routes
232
232
  * this through hosted-boot's stderr seam; consumer failures are ignored. */
233
233
  onDiagnostic?: (message: string) => void
234
+ /** Report genuine harness activity to the session's progress watchdog. */
235
+ onProgress?: () => Promise<void>
234
236
  }
235
237
 
236
238
  export interface HostedLoopResult {
@@ -326,14 +328,22 @@ function detectLoopPrebaked(flag: boolean | undefined, markerPath: string): bool
326
328
  }
327
329
 
328
330
  /** Best-effort global install of the pinned opencode. Non-fatal: a failure is
329
- * recorded and the run finishes cleanly when the subsequent connect fails. */
330
- function installOpenCodePinned(version: string): InstallResult {
331
+ * recorded and the run finishes cleanly when the subsequent connect fails.
332
+ * From 1.18 opencode's postinstall script writes the `opencode` command, and
333
+ * npm 12 blocks it unless allowed, leaving a placeholder that exits 1; so npm
334
+ * allows that one script and the result must print the pinned version
335
+ * (ORI-2523). `env` exists for tests. */
336
+ export function installOpenCodePinned(version: string, env?: NodeJS.ProcessEnv): InstallResult {
331
337
  const command =
332
- `bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
333
- `npm install -g opencode-ai@${version} >/dev/null 2>&1`
334
- const res = spawnSync('bash', ['-c', command], { encoding: 'utf8' })
338
+ `{ bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
339
+ `npm install -g --allow-scripts=opencode-ai opencode-ai@${version} >/dev/null 2>&1; } && ` +
340
+ `v="$(opencode --version 2>/dev/null)" && [ "$v" = '${version}' ]`
341
+ const res = spawnSync('bash', ['-c', command], { encoding: 'utf8', ...(env ? { env } : {}) })
335
342
  const ok = (res.status ?? 1) === 0
336
- return { ok, detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}` }
343
+ return {
344
+ ok,
345
+ detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}, or opencode --version is not ${version}`,
346
+ }
337
347
  }
338
348
 
339
349
  /** Node child_process spawner for `opencode serve` (the sandbox runtime is
@@ -630,10 +640,9 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
630
640
 
631
641
  // Fail-fast model validation (ALI-1086): ask the RUNNING opencode whether
632
642
  // the requested model is resolvable BEFORE the first prompt. The pinned
633
- // opencode's bundled catalog is stale (predates claude-opus-4-8) and only
634
- // a boot-time models.dev fetch (#1392 allowlists it) refreshes it — when
635
- // that fetch is blocked, the first prompt used to die inside the SSE
636
- // stream with an opaque `run_failed: Model not found`. Now the run fails
643
+ // image catalog is baked and refreshed before spawn (ORI-2513). A model
644
+ // absent from that validated list still needs a clear error rather than
645
+ // dying inside the SSE stream with an opaque Model not found. The run fails
637
646
  // immediately with a structured artifact + an error naming the requested
638
647
  // id AND the resolvable alternatives. Fail-open on validator
639
648
  // infrastructure: an unreachable catalog endpoint (`skipped`) proceeds
@@ -784,6 +793,7 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
784
793
  }
785
794
  return
786
795
  }
796
+ if (event.kind !== 'question_auto_answered') await opts.onProgress?.()
787
797
  yield event
788
798
  }
789
799
  // A signal already durable before prompt startup can make a driver end
@@ -0,0 +1,2 @@
1
+ export function refreshHostedModels(options?: { env?: Record<string, string | undefined>; required?: string[]; strict?: boolean; fetchImpl?: typeof fetch; signal?: AbortSignal }): Promise<string>
2
+ export function bakeHostedModels(): Promise<string>
@@ -0,0 +1,53 @@
1
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ // The pinned runtime reads this file once: only replace it with a valid list containing the required models.
6
+ export async function refreshHostedModels({ env = process.env, required = [], strict = false, fetchImpl = fetch, signal } = {}) {
7
+ signal?.throwIfAborted()
8
+ const path = env.OPENCODE_MODELS_PATH || join(env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache'), 'opencode', 'models.json')
9
+ const temporary = `${path}.${process.pid}.tmp`
10
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ const strings = (value, keys) => keys.every(key => value[key] === undefined || typeof value[key] === 'string')
12
+ const cost = value => value === undefined || (record(value) && ['input', 'output', 'cache_read', 'cache_write'].every(key => value[key] === undefined || Number.isFinite(value[key])) && cost(value.context_over_200k))
13
+ const providerOptions = value => value === undefined || (record(value) && strings(value, ['api', 'npm']) &&
14
+ (value.body === undefined || record(value.body)) && (value.headers === undefined || (record(value.headers) && Object.values(value.headers).every(header => typeof header === 'string'))))
15
+ const optionalModelFields = model => {
16
+ if (!strings(model, ['family']) || (model.status !== undefined && !['alpha', 'beta', 'deprecated', 'active'].includes(model.status)) || !cost(model.cost) || !providerOptions(model.provider)) return false
17
+ if (model.limit.input !== undefined && !Number.isFinite(model.limit.input)) return false
18
+ if (model.interleaved !== undefined && typeof model.interleaved !== 'boolean' && (!record(model.interleaved) || !['reasoning_content', 'reasoning_details'].includes(model.interleaved.field))) return false
19
+ if (model.modalities !== undefined && (!record(model.modalities) || !['input', 'output'].every(key => Array.isArray(model.modalities[key]) && model.modalities[key].every(value => ['text', 'audio', 'image', 'video', 'pdf'].includes(value))))) return false
20
+ if (model.experimental !== undefined && (!record(model.experimental) || (model.experimental.modes !== undefined && (!record(model.experimental.modes) || Object.entries(model.experimental.modes).some(([name, mode]) => !name || !record(mode) || !cost(mode.cost) || !providerOptions(mode.provider)))))) return false
21
+ return true
22
+ }
23
+ const identities = text => {
24
+ const catalog = JSON.parse(text)
25
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) throw new Error('Invalid model catalog')
26
+ return Object.entries(catalog).flatMap(([provider, value]) => {
27
+ if (!value || value.id !== provider || !strings(value, ['api', 'npm']) || typeof value.name !== 'string' || !Array.isArray(value.env) || value.env.some(name => typeof name !== 'string') || typeof value.models !== 'object' || !value.models || Array.isArray(value.models)) throw new Error('Invalid model provider')
28
+ return Object.entries(value.models).map(([id, model]) => {
29
+ if (!model || model.id !== id || typeof model.name !== 'string' || typeof model.release_date !== 'string' || ['attachment', 'reasoning', 'tool_call'].some(key => typeof model[key] !== 'boolean') || (model.temperature !== undefined && typeof model.temperature !== 'boolean') || !Number.isFinite(model.limit?.context) || !Number.isFinite(model.limit?.output)) throw new Error('Invalid model entry')
30
+ if (!optionalModelFields(model)) throw new Error('Invalid optional model metadata')
31
+ return ['alpha', 'deprecated'].includes(model.status) ? null : `${provider}/${id}`
32
+ }).filter(Boolean)
33
+ })
34
+ }
35
+ try {
36
+ const response = await fetchImpl(`${env.OPENCODE_MODELS_URL || 'https://models.dev'}/api.json`, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000) })
37
+ if (!response.ok) throw new Error(`Model catalog download failed: ${response.status}`)
38
+ const text = await response.text()
39
+ const models = new Set(identities(text))
40
+ if (!models.size || required.some(id => !models.has(id))) throw new Error('Model catalog is missing required models')
41
+ await mkdir(dirname(path), { recursive: true })
42
+ await writeFile(temporary, text)
43
+ signal?.throwIfAborted()
44
+ await rename(temporary, path)
45
+ } catch (error) {
46
+ signal?.throwIfAborted()
47
+ if (strict) throw error
48
+ } finally { await rm(temporary, { force: true }) }
49
+ signal?.throwIfAborted()
50
+ return path
51
+ }
52
+
53
+ export const bakeHostedModels = () => refreshHostedModels({ strict: true, required: process.env.HOSTED_WORKSPACE_DEFAULT_MODEL ? [process.env.HOSTED_WORKSPACE_DEFAULT_MODEL] : ['openai/gpt-5.6', 'openai/gpt-6-luna', 'anthropic/claude-opus-4-8'] })
package/src/index.ts CHANGED
@@ -155,6 +155,8 @@ import {
155
155
  setDatasetReadme,
156
156
  createDatasetVersion,
157
157
  createDatasetSplitSet,
158
+ listDatasetSplitSets,
159
+ showDatasetSplitSet,
158
160
  editDatasetRows,
159
161
  deleteDatasetRows,
160
162
  deleteDataset,
@@ -2139,6 +2141,14 @@ export async function main(rawArgs = process.argv.slice(2)) {
2139
2141
  await createDatasetVersion()
2140
2142
  return
2141
2143
  }
2144
+ if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'list') {
2145
+ await listDatasetSplitSets()
2146
+ return
2147
+ }
2148
+ if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'show') {
2149
+ await showDatasetSplitSet()
2150
+ return
2151
+ }
2142
2152
  if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'create') {
2143
2153
  await createDatasetSplitSet()
2144
2154
  return
@@ -2252,10 +2262,10 @@ function isCliEntrypoint(): boolean {
2252
2262
  if (isCliEntrypoint()) {
2253
2263
  const rawArgs = process.argv.slice(2)
2254
2264
  withPassiveUpdateNotice(rawArgs, getCliVersion, printError, args =>
2255
- main(args).catch(error => {
2265
+ main(args).catch(async error => {
2256
2266
  const message = error instanceof Error ? error.message : 'Unknown error'
2257
2267
  // The reporter email is unknown on the failure path, so the record masks every address.
2258
- writeLastErrorRecord({
2268
+ await writeLastErrorRecord({
2259
2269
  argv: args,
2260
2270
  message,
2261
2271
  code: extractLastErrorCode(error),
@@ -0,0 +1,123 @@
1
+ import { Cause, Effect, Exit } from 'effect'
2
+
3
+ import { authedFetch } from './http.js'
4
+ import { responsePayload } from './json-response.js'
5
+ import { stageSyncPayload, startSyncLockClaim, tryClaimSyncLock, type SyncDiskOptions, type SyncDiskResult, type SyncPayload, type SyncRequestPlan } from './instruction-set-sync/index.js'
6
+ import { applyUpdate, lockedProfileIdentity, makeUpdatePlan, readUpdateLock, type UpdatePlan, type UpdateResult } from './instruction-set-update/index.js'
7
+
8
+ // Loaded only through `await import(...)` from instruction-sets-cli, so CLI
9
+ // startup and the published loader never load Effect.
10
+
11
+ export interface Interrupted { interrupted: 130 | 143 }
12
+
13
+ // Existing file steps throw plain errors; keep them as the failure value so the
14
+ // CLI prints exactly the same message.
15
+ const attempt = <A>(operation: () => A) => Effect.try({ try: operation, catch: cause => cause })
16
+
17
+ function readPayload(path: string, action: string) {
18
+ return Effect.tryPromise({
19
+ // The body stays unchecked here, as before: stageSyncPayload and makeUpdatePlan validate it.
20
+ // SAFETY: their existing error messages for malformed bodies are part of the CLI's output.
21
+ try: async signal => await responsePayload(await authedFetch(path, { method: 'GET', signal }), action) as unknown as SyncPayload,
22
+ catch: cause => cause,
23
+ })
24
+ }
25
+
26
+ // Waiting for another process's lock is the only interruptible part: nothing
27
+ // is held yet. Publishing runs to completion once the lock is ours.
28
+ function holdSyncLock<A>(appRoot: string, use: Effect.Effect<A, unknown>) {
29
+ const acquire = Effect.gen(function*() {
30
+ const claim = yield* attempt(() => startSyncLockClaim(appRoot))
31
+ while (true) {
32
+ const claimed = yield* attempt(() => tryClaimSyncLock(claim))
33
+ if (typeof claimed === 'function') return claimed
34
+ yield* Effect.interruptible(Effect.sleep(claimed))
35
+ }
36
+ })
37
+ return Effect.acquireUseRelease(acquire, () => use, release => Effect.sync(release))
38
+ }
39
+
40
+ // Stage the version, publish it under the lock, and always remove an
41
+ // unpublished stage, including after Ctrl-C or SIGTERM.
42
+ function deliverPayload(...args: Parameters<typeof stageSyncPayload>): Effect.Effect<SyncDiskResult, unknown> {
43
+ return Effect.acquireUseRelease(
44
+ attempt(() => stageSyncPayload(...args)),
45
+ staged => holdSyncLock(staged.appRoot, attempt(staged.publish)).pipe(
46
+ Effect.map(warnings => ({ destination: staged.destination, wasPresent: staged.wasPresent, warnings })),
47
+ ),
48
+ staged => Effect.sync(staged.discard),
49
+ )
50
+ }
51
+
52
+ async function runOrThrow<A>(program: Effect.Effect<A, unknown>, signal: AbortSignal): Promise<A> {
53
+ const exit = await Effect.runPromiseExit(program, { signal })
54
+ if (Exit.isSuccess(exit)) return exit.value
55
+ throw Cause.squash(exit.cause)
56
+ }
57
+
58
+ // `once`: a second signal falls through to Node's default kill.
59
+ async function runDelivery<A>(program: (signal: AbortSignal) => Effect.Effect<A, unknown>): Promise<A | Interrupted> {
60
+ const controller = new AbortController()
61
+ let interrupted: Interrupted['interrupted'] | undefined
62
+ const handleInterrupt = () => { interrupted ??= 130; controller.abort() }
63
+ const handleTerminate = () => { interrupted ??= 143; controller.abort() }
64
+ process.once('SIGINT', handleInterrupt)
65
+ process.once('SIGTERM', handleTerminate)
66
+ try {
67
+ return await runOrThrow(program(controller.signal), controller.signal)
68
+ } catch (error) {
69
+ if (interrupted !== undefined) return { interrupted }
70
+ throw error
71
+ } finally {
72
+ process.removeListener('SIGINT', handleInterrupt)
73
+ process.removeListener('SIGTERM', handleTerminate)
74
+ }
75
+ }
76
+
77
+ export function syncInstructionSet(out: string, project: string, plan: SyncRequestPlan, options: SyncDiskOptions) {
78
+ return runDelivery(() => Effect.gen(function*() {
79
+ const payload = yield* readPayload(plan.path, 'Instruction sets sync')
80
+ const result = yield* deliverPayload(out, project, plan, payload, options)
81
+ return { payload, result }
82
+ }))
83
+ }
84
+
85
+ export function updateInstructionSets(
86
+ out: string,
87
+ project: string,
88
+ isApproved: boolean,
89
+ noSync: boolean,
90
+ ): Promise<{ plan: UpdatePlan; result: UpdateResult } | Interrupted> {
91
+ const program = (signal: AbortSignal) => Effect.gen(function*() {
92
+ const lock = yield* attempt(() => readUpdateLock(out, project))
93
+ const payloads: SyncPayload[] = []
94
+ for (const [setSlug, set] of Object.entries(lock.instructionSets)) {
95
+ const barePath = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?project=${encodeURIComponent(project)}`
96
+ payloads.push(yield* readPayload(barePath, 'Instruction sets update'))
97
+ for (const profileSlugValue of Object.keys(set.profiles)) {
98
+ const identity = yield* attempt(() => lockedProfileIdentity(out, setSlug, profileSlugValue))
99
+ const query = new URLSearchParams({ project, profile: identity })
100
+ const path = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?${query.toString()}`
101
+ payloads.push(yield* readPayload(path, 'Instruction sets update'))
102
+ }
103
+ }
104
+ const plan = yield* attempt(() => makeUpdatePlan(project, lock, payloads))
105
+ if (!isApproved) return { plan, result: { absent: [], warnings: [] } }
106
+ // applyUpdate's own steps are not interruptible; each version it syncs is
107
+ // a nested delivery that the shared signal interrupts and cleans up.
108
+ const result = yield* Effect.uninterruptible(Effect.tryPromise({
109
+ try: () => applyUpdate(out, project, plan, noSync, undefined,
110
+ (...args) => runOrThrow(deliverPayload(...args), signal)),
111
+ catch: cause => cause,
112
+ }))
113
+ return { plan, result }
114
+ })
115
+ // --no-sync stages nothing to clean up, so keep Node's default signal
116
+ // handling: a signal stops it at once instead of being held until the
117
+ // update has already landed.
118
+ if (noSync) {
119
+ const signal = new AbortController().signal
120
+ return runOrThrow(program(signal), signal)
121
+ }
122
+ return runDelivery(program)
123
+ }