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.
- package/README.md +77 -0
- package/dist/dataset-clone-workflow.d.ts +11 -0
- package/dist/dataset-clone-workflow.d.ts.map +1 -0
- package/dist/dataset-clone-workflow.js +100 -0
- package/dist/dataset-commands.d.ts +3 -1
- package/dist/dataset-commands.d.ts.map +1 -1
- package/dist/dataset-commands.js +107 -91
- package/dist/dataset-delete-workflow.d.ts +4 -0
- package/dist/dataset-delete-workflow.d.ts.map +1 -0
- package/dist/dataset-delete-workflow.js +75 -0
- package/dist/dataset-lock-workflow.d.ts +12 -0
- package/dist/dataset-lock-workflow.d.ts.map +1 -0
- package/dist/dataset-lock-workflow.js +76 -0
- package/dist/dataset-readme-workflow.d.ts +13 -0
- package/dist/dataset-readme-workflow.d.ts.map +1 -0
- package/dist/dataset-readme-workflow.js +82 -0
- package/dist/dataset-row-workflow.d.ts +14 -0
- package/dist/dataset-row-workflow.d.ts.map +1 -0
- package/dist/dataset-row-workflow.js +202 -0
- package/dist/dataset-split-reads.d.ts +28 -0
- package/dist/dataset-split-reads.d.ts.map +1 -0
- package/dist/dataset-split-reads.js +47 -0
- package/dist/dataset-transfer-upload.d.ts +2 -6
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +9 -2
- package/dist/dataset-upload-snapshot.d.ts.map +1 -1
- package/dist/dataset-upload-snapshot.js +7 -1
- package/dist/dataset-version-workflow.d.ts +24 -0
- package/dist/dataset-version-workflow.d.ts.map +1 -0
- package/dist/dataset-version-workflow.js +99 -0
- package/dist/feedback-cli.js +3 -3
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +29 -3
- package/dist/hosted-boot.d.ts +1 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +17 -0
- package/dist/hosted-harness-opencode.d.ts +5 -6
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +73 -59
- package/dist/hosted-loop-lifecycle.d.ts +6 -11
- package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
- package/dist/hosted-loop-lifecycle.js +6 -11
- package/dist/hosted-loop.d.ts +9 -0
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +19 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -3
- package/dist/instruction-delivery.d.ts +14 -0
- package/dist/instruction-delivery.d.ts.map +1 -0
- package/dist/instruction-delivery.js +103 -0
- package/dist/instruction-set-sync/index.d.ts +17 -0
- package/dist/instruction-set-sync/index.d.ts.map +1 -1
- package/dist/instruction-set-sync/index.js +44 -17
- package/dist/instruction-set-update/index.d.ts +2 -2
- package/dist/instruction-set-update/index.d.ts.map +1 -1
- package/dist/instruction-set-update/index.js +2 -2
- package/dist/instruction-sets-cli.d.ts.map +1 -1
- package/dist/instruction-sets-cli.js +19 -27
- package/dist/json-response.d.ts +1 -0
- package/dist/json-response.d.ts.map +1 -1
- package/dist/json-response.js +7 -0
- package/dist/last-error-record.d.ts +4 -2
- package/dist/last-error-record.d.ts.map +1 -1
- package/dist/last-error-record.js +10 -68
- package/dist/last-error-workflow.d.ts +4 -0
- package/dist/last-error-workflow.d.ts.map +1 -0
- package/dist/last-error-workflow.js +78 -0
- package/dist/preview-runtime.d.ts +2 -2
- package/dist/preview-runtime.d.ts.map +1 -1
- package/dist/preview-runtime.js +37 -7
- package/dist/task-commands.d.ts.map +1 -1
- package/dist/task-commands.js +9 -16
- package/dist/task-export-workflow.d.ts +4 -0
- package/dist/task-export-workflow.d.ts.map +1 -0
- package/dist/task-export-workflow.js +46 -0
- package/dist/task-report-workflow.d.ts +47 -0
- package/dist/task-report-workflow.d.ts.map +1 -0
- package/dist/task-report-workflow.js +160 -0
- package/dist/workbench-cli.js +1 -1
- package/package.json +1 -1
- package/src/dataset-clone-workflow.ts +100 -0
- package/src/dataset-commands.ts +95 -142
- package/src/dataset-delete-workflow.ts +72 -0
- package/src/dataset-lock-workflow.ts +74 -0
- package/src/dataset-readme-workflow.ts +80 -0
- package/src/dataset-row-workflow.ts +248 -0
- package/src/dataset-split-reads.ts +43 -0
- package/src/dataset-transfer-upload.ts +12 -5
- package/src/dataset-upload-snapshot.ts +8 -1
- package/src/dataset-version-workflow.ts +105 -0
- package/src/feedback-cli.ts +4 -4
- package/src/help.ts +29 -3
- package/src/hosted-boot.ts +16 -1
- package/src/hosted-harness-opencode.ts +74 -72
- package/src/hosted-loop-lifecycle.ts +6 -11
- package/src/hosted-loop.ts +20 -10
- package/src/hosted-model-cache.d.mts +2 -0
- package/src/hosted-model-cache.mjs +53 -0
- package/src/index.ts +12 -2
- package/src/instruction-delivery.ts +123 -0
- package/src/instruction-set-sync/index.ts +61 -14
- package/src/instruction-set-update/index.ts +4 -3
- package/src/instruction-sets-cli.ts +19 -27
- package/src/json-response.ts +8 -0
- package/src/last-error-record.ts +11 -85
- package/src/last-error-workflow.ts +121 -0
- package/src/preview-runtime.ts +40 -10
- package/src/task-commands.ts +9 -43
- package/src/task-export-workflow.ts +56 -0
- package/src/task-report-workflow.ts +179 -0
- package/src/workbench-cli.ts +1 -1
- package/vendor/skills/orizu/references/cli-reference.md +34 -0
- package/vendor/skills/orizu/references/dataset-design.md +1 -1
- package/dist/dataset-edit-rows.d.ts +0 -10
- package/dist/dataset-edit-rows.d.ts.map +0 -1
- package/dist/dataset-edit-rows.js +0 -90
- package/src/dataset-edit-rows.ts +0 -124
package/src/hosted-boot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
export const OPENCODE_PINNED_VERSION = '1.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
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
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
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
|
|
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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
package/src/hosted-loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
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,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
|
+
}
|