orizu 0.5.49 → 0.5.50

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 (74) hide show
  1. package/dist/account-directory.d.ts +2 -2
  2. package/dist/account-directory.d.ts.map +1 -1
  3. package/dist/account-directory.js +7 -32
  4. package/dist/auth-commands.d.ts.map +1 -1
  5. package/dist/auth-commands.js +22 -30
  6. package/dist/credentials.d.ts +5 -0
  7. package/dist/credentials.d.ts.map +1 -1
  8. package/dist/credentials.js +84 -3
  9. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  10. package/dist/dataset-transfer-upload.js +6 -1
  11. package/dist/directory-list-read.d.ts +37 -0
  12. package/dist/directory-list-read.d.ts.map +1 -0
  13. package/dist/directory-list-read.js +87 -0
  14. package/dist/hosted-boot.d.ts.map +1 -1
  15. package/dist/hosted-boot.js +59 -41
  16. package/dist/hosted-harness-opencode.d.ts +31 -0
  17. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  18. package/dist/hosted-harness-opencode.js +45 -7
  19. package/dist/hosted-loop.d.ts +39 -2
  20. package/dist/hosted-loop.d.ts.map +1 -1
  21. package/dist/hosted-loop.js +127 -18
  22. package/dist/hosted-session-seed.d.ts +2 -0
  23. package/dist/hosted-session-seed.d.ts.map +1 -1
  24. package/dist/hosted-session-seed.js +27 -10
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +39 -234
  27. package/dist/keyboard-select.d.ts.map +1 -1
  28. package/dist/keyboard-select.js +23 -45
  29. package/dist/project-create-workflow.d.ts +18 -0
  30. package/dist/project-create-workflow.d.ts.map +1 -0
  31. package/dist/project-create-workflow.js +121 -0
  32. package/dist/self-update-command.d.ts +8 -0
  33. package/dist/self-update-command.d.ts.map +1 -0
  34. package/dist/self-update-command.js +390 -0
  35. package/dist/self-update.d.ts +29 -16
  36. package/dist/self-update.d.ts.map +1 -1
  37. package/dist/self-update.js +55 -244
  38. package/dist/setup-account.d.ts.map +1 -1
  39. package/dist/setup-account.js +4 -1
  40. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  41. package/dist/setup-and-skills-commands.js +88 -102
  42. package/dist/skill-install-workflow.d.ts +23 -0
  43. package/dist/skill-install-workflow.d.ts.map +1 -0
  44. package/dist/skill-install-workflow.js +184 -0
  45. package/dist/skill-installer.d.ts +8 -3
  46. package/dist/skill-installer.d.ts.map +1 -1
  47. package/dist/skill-installer.js +26 -33
  48. package/dist/stored-session-requests.d.ts +27 -0
  49. package/dist/stored-session-requests.d.ts.map +1 -0
  50. package/dist/stored-session-requests.js +264 -0
  51. package/dist/terminal-key-session.d.ts +23 -0
  52. package/dist/terminal-key-session.d.ts.map +1 -0
  53. package/dist/terminal-key-session.js +74 -0
  54. package/package.json +1 -1
  55. package/src/account-directory.ts +8 -42
  56. package/src/auth-commands.ts +20 -29
  57. package/src/credentials.ts +83 -3
  58. package/src/dataset-transfer-upload.ts +7 -1
  59. package/src/directory-list-read.ts +123 -0
  60. package/src/hosted-boot.ts +65 -43
  61. package/src/hosted-harness-opencode.ts +65 -9
  62. package/src/hosted-loop.ts +152 -18
  63. package/src/hosted-session-seed.ts +25 -8
  64. package/src/http.ts +39 -317
  65. package/src/keyboard-select.ts +18 -55
  66. package/src/project-create-workflow.ts +139 -0
  67. package/src/self-update-command.ts +419 -0
  68. package/src/self-update.ts +70 -266
  69. package/src/setup-account.ts +4 -1
  70. package/src/setup-and-skills-commands.ts +89 -121
  71. package/src/skill-install-workflow.ts +215 -0
  72. package/src/skill-installer.ts +31 -38
  73. package/src/stored-session-requests.ts +316 -0
  74. package/src/terminal-key-session.ts +88 -0
@@ -53,13 +53,17 @@ import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.j
53
53
  import {
54
54
  DEFAULT_PROMPT_MAX_DURATION_MS,
55
55
  OPENCODE_PINNED_VERSION,
56
+ OpenCodeExitedError,
56
57
  awaitOpenCodeModelResolvable,
57
58
  createOpenCodeHarness,
58
59
  permissionForOrigin,
59
60
  spawnOpenCode,
61
+ startOpenCodeProcess,
62
+ waitOpenCodeReady,
60
63
  type ModelValidationOutcome,
61
64
  type SpawnOpenCodeOptions,
62
65
  type SpawnedOpenCode,
66
+ type StartedOpenCode,
63
67
  } from './hosted-harness-opencode.js'
64
68
  import { createClaudeAgentHarness } from './hosted-harness-claude.js'
65
69
  import { INJECTED_ENV_VARS_ENV } from './hosted-environment.js'
@@ -137,6 +141,25 @@ interface HostedLoopSharedRuntime {
137
141
  startupComplete: boolean
138
142
  spawnLogPath: string
139
143
  closed: boolean
144
+ /** ORI-2512: the boot's background start, until the first turn takes it. */
145
+ prestart?: HostedPrestart | null
146
+ }
147
+
148
+ /**
149
+ * ORI-2512 cut 3: the egress canary's probes and the OpenCode start, begun by
150
+ * the boot while the repository clones. The first turn records the canary's
151
+ * events from the stored answers (same decision, same events) and waits for
152
+ * the started server, which is asked nothing before then.
153
+ */
154
+ export interface HostedPrestart {
155
+ /** The probes' answers by host, and the canary's verdict. Null when a
156
+ * probe threw: the turn then runs the canary itself. Never rejects. */
157
+ canary: Promise<{ results: Map<string, EgressProbeResult>; checkedAt: number; proceed: boolean } | null>
158
+ /** The started process; null when the canary said no or the start was
159
+ * stopped. Rejects when the background start failed. */
160
+ opencode: Promise<StartedOpenCode | null>
161
+ /** Stop the process, now or as soon as it starts. */
162
+ stop: () => void
140
163
  }
141
164
 
142
165
  export interface HostedLoopSessionContext extends Omit<HostedLoopContext, 'runId' | 'bearerFile' | 'taskFile' | 'messageId'> {
@@ -362,7 +385,7 @@ export function installOpenCodePinned(version: string, env?: NodeJS.ProcessEnv):
362
385
  function nodeChildSpawner(
363
386
  cmd: string[],
364
387
  opts: { cwd?: string; env: Record<string, string>; logPath?: string }
365
- ): { kill: () => void } {
388
+ ): { kill: () => void; hasExited: () => boolean; exitReason: () => string | null } {
366
389
  let stdio: 'ignore' | Array<'ignore' | number> = 'ignore'
367
390
  let fd: number | null = null
368
391
  if (opts.logPath) {
@@ -386,7 +409,81 @@ function nodeChildSpawner(
386
409
  // already closed
387
410
  }
388
411
  }
389
- return { kill: () => child.kill() }
412
+ // ORI-2512: lets the readiness wait stop at once for a process that has
413
+ // gone. The error listener also keeps a failed spawn from crashing the boot.
414
+ let exited = false
415
+ let spawnError: string | null = null
416
+ child.once('exit', () => { exited = true })
417
+ child.once('error', error => {
418
+ exited = true
419
+ spawnError = error instanceof Error ? error.message : String(error)
420
+ })
421
+ return { kill: () => child.kill(), hasExited: () => exited, exitReason: () => spawnError }
422
+ }
423
+
424
+ /** The one place the hosted `opencode serve` options come from, for both the
425
+ * boot's background start and the turn's start in order. */
426
+ function openCodeSpawnOptions(context: HostedLoopContext, logPath: string): SpawnOpenCodeOptions {
427
+ return {
428
+ model: context.model,
429
+ permission: permissionForOrigin(context.sessionOrigin),
430
+ projectId: context.projectId,
431
+ cwd: context.workspaceDir,
432
+ port: context.opencodePort,
433
+ env: buildOpenCodeSpawnEnv(context),
434
+ spawn: nodeChildSpawner,
435
+ logPath,
436
+ }
437
+ }
438
+
439
+ /**
440
+ * ORI-2512 cut 3: start the egress canary and then OpenCode in the
441
+ * background, for the boot to call before it clones. The order is the
442
+ * loop's: the canary first, fail-closed, before the models.dev refresh or
443
+ * OpenCode. Nothing here records events; the first turn does. Only for the
444
+ * pre-baked OpenCode runtime under an enforced egress policy; otherwise null
445
+ * and the turn starts everything in order.
446
+ */
447
+ export function prestartHostedOpenCode(
448
+ context: HostedLoopContext,
449
+ opts: { logPath: string }
450
+ ): HostedPrestart | null {
451
+ if ((context.harness ?? 'opencode') !== 'opencode' || !context.egressCanaryHost || context.prebaked !== true) return null
452
+ const targets: EgressCanaryTargets = {
453
+ allowedHost: egressCanaryAllowedHost(context.apiBaseUrl),
454
+ deniedHost: context.egressCanaryHost,
455
+ }
456
+ const results = new Map<string, EgressProbeResult>()
457
+ let stopped = false
458
+ const canary = (async () => {
459
+ let checkedAt = Date.now()
460
+ // The loop's own decision, with nothing recorded yet.
461
+ const decision = await runEgressCanary(targets, { append: async () => {} }, async host => {
462
+ const result = await defaultProbeEgress(host)
463
+ results.set(host, result)
464
+ return result
465
+ }, () => (checkedAt = Date.now()))
466
+ return { results, checkedAt, proceed: decision.proceed }
467
+ })().catch(() => null)
468
+ const opencode = canary.then(async stored => {
469
+ if (!stored?.proceed || stopped) return null
470
+ const started = await startOpenCodeProcess(openCodeSpawnOptions(context, opts.logPath))
471
+ if (stopped) {
472
+ started.stop()
473
+ return null
474
+ }
475
+ return started
476
+ })
477
+ // The turn reads a failure; until then it must not count as unhandled.
478
+ opencode.catch(() => {})
479
+ return {
480
+ canary,
481
+ opencode,
482
+ stop: () => {
483
+ stopped = true
484
+ void opencode.then(started => started?.stop(), () => {})
485
+ },
486
+ }
390
487
  }
391
488
 
392
489
  /** ORI-2512: the longest the end-of-turn save waits for the boot's
@@ -449,15 +546,22 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
449
546
  // unreachable → network broken / indistinguishable from a block) emits
450
547
  // `egress_allowed` and finishes the run FAILED rather than touch real customer
451
548
  // data. A canary whose own event append fails is likewise fail-closed.
549
+ // ORI-2512: on the first turn, the boot may already have probed in the
550
+ // background; the same decision is then made from its stored answers.
551
+ const prestart = opts.sharedRuntime && !opts.sharedRuntime.startupComplete ? opts.sharedRuntime.prestart : null
452
552
  if (context.egressCanaryHost && !opts.sharedRuntime?.startupComplete) {
453
- const probe = opts.probeEgress ?? defaultProbeEgress
553
+ const liveProbe = opts.probeEgress ?? defaultProbeEgress
554
+ const stored = prestart ? await prestart.canary : null
555
+ const probe = stored
556
+ ? async (host: string): Promise<EgressProbeResult> => stored.results.get(host) ?? liveProbe(host)
557
+ : liveProbe
454
558
  const targets: EgressCanaryTargets = {
455
559
  allowedHost: egressCanaryAllowedHost(context.apiBaseUrl),
456
560
  deniedHost: context.egressCanaryHost,
457
561
  }
458
562
  let canary: EgressCanaryDecision
459
563
  try {
460
- canary = await runEgressCanary(targets, sink, probe, opts.now)
564
+ canary = await runEgressCanary(targets, sink, probe, stored ? () => stored.checkedAt : opts.now)
461
565
  } catch (error) {
462
566
  // The canary's own event append failed — treat as fail-closed: we cannot
463
567
  // prove the firewall is live, so do not proceed.
@@ -666,26 +770,49 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
666
770
  })
667
771
  }
668
772
 
773
+ const logPath = opts.sharedRuntime?.spawnLogPath ?? join(tmpdir(), `opencode-serve-${context.runId}.log`)
774
+ // ORI-2512: the first turn takes the server the boot started during the
775
+ // clone and asks it something only now, after the clone. A background
776
+ // start that failed, or a process that already exited, is started again
777
+ // here in order.
778
+ const waitStartedAt = Date.now()
779
+ let prestarted: SpawnedOpenCode | null = null
780
+ if (prestart && opts.sharedRuntime) {
781
+ opts.sharedRuntime.prestart = null
782
+ let started: StartedOpenCode | null = null
783
+ try {
784
+ started = await prestart.opencode
785
+ } catch {
786
+ diagnose('hosted OpenCode background start failed; starting it in order')
787
+ }
788
+ if (started) {
789
+ try {
790
+ prestarted = await waitOpenCodeReady(started, { logPath, signal })
791
+ } catch (error) {
792
+ if (!(error instanceof OpenCodeExitedError)) throw error
793
+ diagnose('hosted OpenCode background start exited; starting it in order')
794
+ }
795
+ }
796
+ }
669
797
  const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
670
798
  // The default spawnOpenCode WAITS for the server to answer HTTP before
671
799
  // returning — OpenCode's first boot runs a one-time sqlite migration, and
672
800
  // starting the harness before the server listens raced straight into a
673
801
  // connection-refused "fetch failed" (ALI-1034).
674
- spawned = await spawnImpl({
675
- model: context.model,
676
- permission: permissionForOrigin(context.sessionOrigin),
677
- projectId: context.projectId,
678
- cwd: context.workspaceDir,
679
- port: context.opencodePort,
680
- env: buildOpenCodeSpawnEnv(context),
681
- spawn: nodeChildSpawner,
682
- logPath: opts.sharedRuntime?.spawnLogPath ?? join(tmpdir(), `opencode-serve-${context.runId}.log`),
683
- signal,
684
- })
802
+ spawned = prestarted ?? await spawnImpl({ ...openCodeSpawnOptions(context, logPath), signal })
685
803
  if (typeof spawned.readyAfterMs === 'number') {
686
804
  await sink.append({
687
805
  kind: 'artifact',
688
- payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
806
+ payload: {
807
+ step: 'opencode_ready',
808
+ ok: true,
809
+ // How long this turn waited for OpenCode. When the boot started it
810
+ // during the clone, that is the wait after the clone; the time
811
+ // since it was started (clone included) is kept apart.
812
+ ...(prestarted
813
+ ? { waitedMs: Date.now() - waitStartedAt, prestarted: true, sinceStartMs: spawned.readyAfterMs }
814
+ : { waitedMs: spawned.readyAfterMs }),
815
+ },
689
816
  })
690
817
  }
691
818
 
@@ -979,7 +1106,8 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
979
1106
  }
980
1107
 
981
1108
  export async function startHostedLoopSession(
982
- context: HostedLoopSessionContext
1109
+ context: HostedLoopSessionContext,
1110
+ options: { prestart?: HostedPrestart | null } = {}
983
1111
  ): Promise<HostedLoopSessionHandle> {
984
1112
  return {
985
1113
  context,
@@ -989,12 +1117,18 @@ export async function startHostedLoopSession(
989
1117
  agentSessionId: null,
990
1118
  installOk: false,
991
1119
  startupComplete: false,
992
- spawnLogPath: join(context.sessionDir, `opencode-serve-${context.sessionId}.log`),
1120
+ spawnLogPath: hostedSessionOpenCodeLogPath(context),
993
1121
  closed: false,
1122
+ prestart: options.prestart ?? null,
994
1123
  },
995
1124
  }
996
1125
  }
997
1126
 
1127
+ /** Where a hosted session's `opencode serve` writes its output. */
1128
+ export function hostedSessionOpenCodeLogPath(context: Pick<HostedLoopSessionContext, 'sessionDir' | 'sessionId'>): string {
1129
+ return join(context.sessionDir, `opencode-serve-${context.sessionId}.log`)
1130
+ }
1131
+
998
1132
  export async function runHostedLoopTurn(
999
1133
  session: HostedLoopSessionHandle,
1000
1134
  turn: HostedLoopTurnContext,
@@ -17,6 +17,9 @@
17
17
  * boot's credential helper), never in a URL or argument.
18
18
  */
19
19
 
20
+ import { existsSync, readdirSync, renameSync } from 'fs'
21
+ import { join } from 'path'
22
+
20
23
  export const SESSION_COPY_SEED_FAILURE = 'session copy could not be filled from the team copy'
21
24
 
22
25
  interface SeedExecResult {
@@ -109,6 +112,8 @@ export interface CloneTeamIntoWorkTreeInput {
109
112
  scratchDir: string
110
113
  /** The boot's working folder: absent or empty. */
111
114
  workTree: string
115
+ /** Moves one entry into the working folder (default: renameSync). */
116
+ rename?: (from: string, to: string) => void
112
117
  }
113
118
 
114
119
  export type CloneTeamIntoWorkTreeResult =
@@ -156,14 +161,26 @@ export async function cloneTeamIntoWorkTree(input: CloneTeamIntoWorkTreeInput):
156
161
  if (fetched.status !== 0) throw failure('reading the team copy', fetched)
157
162
  const pointed = input.exec('git', ['-C', input.scratchDir, 'remote', 'set-url', 'origin', input.sessionRemote])
158
163
  if (pointed.status !== 0) throw failure('pointing the working folder at the session copy', pointed)
159
- // The runtime image may ship the folder empty; `mv` needs it gone.
160
- input.exec('rmdir', ['--', input.workTree])
161
- // A folder that is still there (not empty, or a mount point) would make
162
- // `mv` put the clone inside it.
163
- const remaining = input.exec('test', ['-e', input.workTree])
164
- if (remaining.status === 0) throw failure('making the working folder', { status: 1, stdout: '', stderr: `${input.workTree} is not empty` })
165
- const moved = input.exec('mv', ['--', input.scratchDir, input.workTree])
166
- if (moved.status !== 0) throw failure('making the working folder', moved)
164
+ // ORI-2512: an existing folder (the runtime image may ship it empty, and
165
+ // the boot may have started OpenCode in it) is filled in place, never
166
+ // replaced: a process already started there would be left in a deleted
167
+ // folder. A missing folder becomes the clone.
168
+ if (existsSync(input.workTree)) {
169
+ if (readdirSync(input.workTree).length > 0) throw failure('making the working folder', { status: 1, stdout: '', stderr: `${input.workTree} is not empty` })
170
+ try {
171
+ // `.git` last: a fill that stops partway leaves no repository, which
172
+ // the next boot refuses, rather than one missing files that a save
173
+ // would push as deleted.
174
+ const entries = readdirSync(input.scratchDir).sort((a, b) => Number(a === '.git') - Number(b === '.git'))
175
+ const rename = input.rename ?? renameSync
176
+ for (const entry of entries) rename(join(input.scratchDir, entry), join(input.workTree, entry))
177
+ } catch (error) {
178
+ throw failure('making the working folder', { status: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error) })
179
+ }
180
+ } else {
181
+ const moved = input.exec('mv', ['--', input.scratchDir, input.workTree])
182
+ if (moved.status !== 0) throw failure('making the working folder', moved)
183
+ }
167
184
  } finally {
168
185
  clear()
169
186
  }