orizu 0.5.41 → 0.5.42

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 (90) hide show
  1. package/dist/account-directory.d.ts.map +1 -1
  2. package/dist/account-directory.js +2 -10
  3. package/dist/app-publish-workspace.js +1 -1
  4. package/dist/archive-artifact-workflow.d.ts +23 -0
  5. package/dist/archive-artifact-workflow.d.ts.map +1 -0
  6. package/dist/archive-artifact-workflow.js +100 -0
  7. package/dist/archive-artifacts-cli.d.ts.map +1 -1
  8. package/dist/archive-artifacts-cli.js +7 -11
  9. package/dist/dataset-commands.d.ts.map +1 -1
  10. package/dist/dataset-commands.js +19 -29
  11. package/dist/dataset-split-create-workflow.d.ts +16 -0
  12. package/dist/dataset-split-create-workflow.d.ts.map +1 -0
  13. package/dist/dataset-split-create-workflow.js +115 -0
  14. package/dist/git-credential.js +1 -1
  15. package/dist/help.d.ts.map +1 -1
  16. package/dist/help.js +10 -2
  17. package/dist/hosted-boot.d.ts +7 -0
  18. package/dist/hosted-boot.d.ts.map +1 -1
  19. package/dist/hosted-boot.js +251 -58
  20. package/dist/hosted-harness.d.ts +1 -0
  21. package/dist/hosted-harness.d.ts.map +1 -1
  22. package/dist/hosted-harness.js +2 -0
  23. package/dist/hosted-harvest.d.ts +2 -1
  24. package/dist/hosted-harvest.d.ts.map +1 -1
  25. package/dist/hosted-harvest.js +29 -17
  26. package/dist/hosted-live-channel.d.ts +25 -0
  27. package/dist/hosted-live-channel.d.ts.map +1 -0
  28. package/dist/hosted-live-channel.js +130 -0
  29. package/dist/hosted-loop.d.ts.map +1 -1
  30. package/dist/hosted-loop.js +18 -0
  31. package/dist/hosted-run-event-sink.d.ts +3 -0
  32. package/dist/hosted-run-event-sink.d.ts.map +1 -1
  33. package/dist/hosted-run-event-sink.js +4 -0
  34. package/dist/hosted-runtime-assets.js +1 -1
  35. package/dist/hosted-session-cli.d.ts.map +1 -1
  36. package/dist/hosted-session-cli.js +4 -0
  37. package/dist/hosted-session-seed.d.ts +8 -6
  38. package/dist/hosted-session-seed.d.ts.map +1 -1
  39. package/dist/hosted-session-seed.js +26 -9
  40. package/dist/hosted-skill-staging.d.ts +36 -12
  41. package/dist/hosted-skill-staging.d.ts.map +1 -1
  42. package/dist/hosted-skill-staging.js +96 -34
  43. package/dist/http.d.ts +2 -1
  44. package/dist/http.d.ts.map +1 -1
  45. package/dist/http.js +10 -6
  46. package/dist/orizu-skill-links.d.ts +48 -0
  47. package/dist/orizu-skill-links.d.ts.map +1 -0
  48. package/dist/orizu-skill-links.js +99 -0
  49. package/dist/preview-runtime.d.ts +1 -0
  50. package/dist/preview-runtime.d.ts.map +1 -1
  51. package/dist/preview-runtime.js +15 -0
  52. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  53. package/dist/setup-and-skills-commands.js +52 -7
  54. package/dist/setup-onboarding.d.ts.map +1 -1
  55. package/dist/setup-onboarding.js +6 -2
  56. package/dist/skill-installer.d.ts +16 -0
  57. package/dist/skill-installer.d.ts.map +1 -1
  58. package/dist/skill-installer.js +33 -5
  59. package/dist/team-create-workflow.d.ts +16 -0
  60. package/dist/team-create-workflow.d.ts.map +1 -0
  61. package/dist/team-create-workflow.js +130 -0
  62. package/dist/workbench-cli.d.ts.map +1 -1
  63. package/dist/workbench-cli.js +24 -4
  64. package/package.json +1 -1
  65. package/src/account-directory.ts +2 -10
  66. package/src/app-publish-workspace.ts +1 -1
  67. package/src/archive-artifact-workflow.ts +101 -0
  68. package/src/archive-artifacts-cli.ts +7 -28
  69. package/src/dataset-commands.ts +19 -31
  70. package/src/dataset-split-create-workflow.ts +128 -0
  71. package/src/git-credential.ts +1 -1
  72. package/src/help.ts +10 -2
  73. package/src/hosted-boot.ts +252 -57
  74. package/src/hosted-harness.ts +2 -0
  75. package/src/hosted-harvest.ts +30 -17
  76. package/src/hosted-live-channel.ts +136 -0
  77. package/src/hosted-loop.ts +16 -0
  78. package/src/hosted-run-event-sink.ts +7 -0
  79. package/src/hosted-runtime-assets.ts +1 -1
  80. package/src/hosted-session-cli.ts +4 -0
  81. package/src/hosted-session-seed.ts +28 -10
  82. package/src/hosted-skill-staging.ts +110 -36
  83. package/src/http.ts +12 -7
  84. package/src/orizu-skill-links.ts +114 -0
  85. package/src/preview-runtime.ts +16 -0
  86. package/src/setup-and-skills-commands.ts +60 -6
  87. package/src/setup-onboarding.ts +6 -2
  88. package/src/skill-installer.ts +44 -5
  89. package/src/team-create-workflow.ts +132 -0
  90. package/src/workbench-cli.ts +24 -4
@@ -30,7 +30,8 @@
30
30
  * non-secret ANTHROPIC_API_KEY dummy placeholder (firewall brokers the real key).
31
31
  */
32
32
  import { spawnSync } from 'child_process';
33
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'fs';
33
+ import { randomUUID } from 'crypto';
34
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'fs';
34
35
  import { isValidCloudflareArtifactsGitRemote } from './cloudflare-artifacts-git-remote.js';
35
36
  import { seedSessionCopy } from './hosted-session-seed.js';
36
37
  import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, REPO_CRED_CACHE_BASENAME, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
@@ -39,6 +40,7 @@ import { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL } from './hosted-loop-
39
40
  import { composeHostedAnswerPrompt } from './hosted-question.js';
40
41
  import { resumeRunEventSink } from './hosted-run-event-sink.js';
41
42
  import { stageOrizuSkill } from './hosted-skill-staging.js';
43
+ import { openHostedLiveChannel } from './hosted-live-channel.js';
42
44
  /** Non-secret placeholder model key (firewall brokers the real org key on
43
45
  * egress). Byte-for-byte the operator path's constant
44
46
  * (packages/cli/src/hosted-session-cli.ts `ANTHROPIC_DUMMY_KEY` and the
@@ -314,7 +316,7 @@ function asPositiveNumber(value) {
314
316
  /** GET the session (agent-capable, RLS-scoped) to learn its workspace, branch,
315
317
  * task, and any existing run — the DO start body carries none of these. */
316
318
  export async function resolveSession(opts) {
317
- const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, opts.bearer, opts.signal ? { signal: opts.signal } : {});
319
+ const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, opts.bearer, { headers: { 'X-Orizu-Session-Seed-Protocol': '1' }, ...(opts.signal ? { signal: opts.signal } : {}) });
318
320
  const session = (body.session ?? {});
319
321
  const workspaceId = asString(session.workspaceId);
320
322
  if (!workspaceId)
@@ -322,6 +324,8 @@ export async function resolveSession(opts) {
322
324
  const repoBranch = asString(session.repoBranch);
323
325
  if (!repoBranch)
324
326
  throw new Error('session response carried no repoBranch (branch not provisioned)');
327
+ if (session.seedOrigin !== undefined && session.seedOrigin !== 'team' && session.seedOrigin !== 'session')
328
+ throw new Error('session response carried invalid seedOrigin');
325
329
  const clientInfo = (session.clientInfo ?? {});
326
330
  const initialTask = asString(clientInfo.task);
327
331
  if (!initialTask)
@@ -374,6 +378,8 @@ export async function resolveSession(opts) {
374
378
  pendingTurn: unfinishedRun ? pendingTurn : null,
375
379
  interruptRequestedRunId: asString(session.interruptRequestedRunId),
376
380
  agentSessionId: asString(session.agentSessionId),
381
+ // A server with copying switched off omits seedOrigin; no source is bound.
382
+ seedOrigin: session.seedOrigin === 'session' ? 'session' : 'team',
377
383
  projectId: asString(session.projectId),
378
384
  };
379
385
  }
@@ -383,6 +389,44 @@ export const HOSTED_SESSION_DEFAULT_POLL_MS = 5_000;
383
389
  export async function waitForNextHostedSessionRead(sleep, pollMs = HOSTED_SESSION_DEFAULT_POLL_MS) {
384
390
  await sleep(pollMs);
385
391
  }
392
+ function sleepUntil(signal) {
393
+ return (ms) => new Promise(resolve => {
394
+ if (signal.aborted) {
395
+ resolve();
396
+ return;
397
+ }
398
+ const timer = setTimeout(finish, ms);
399
+ const handleAbort = () => finish();
400
+ function finish() {
401
+ clearTimeout(timer);
402
+ signal.removeEventListener('abort', handleAbort);
403
+ resolve();
404
+ }
405
+ signal.addEventListener('abort', handleAbort, { once: true });
406
+ });
407
+ }
408
+ /** ORI-2200: wait for the next session read, or less when the coordinator says
409
+ * "check now". The read still decides what happens; a check alone does nothing. */
410
+ async function waitForReadOrCheck(opts) {
411
+ if (!opts.live) {
412
+ await waitForNextHostedSessionRead(opts.sleep ?? sleepUntil(opts.signal), opts.pollMs);
413
+ return;
414
+ }
415
+ const woken = new AbortController();
416
+ const wait = AbortSignal.any([opts.signal, woken.signal]);
417
+ await Promise.race([
418
+ waitForNextHostedSessionRead(opts.sleep ?? sleepUntil(wait), opts.pollMs),
419
+ opts.live.nextCheck(wait),
420
+ ]);
421
+ woken.abort();
422
+ }
423
+ /** The sandbox's live-socket pass route sits beside its agent-token route. */
424
+ export function deriveLiveSandboxPassUrl(agentTokenUrl) {
425
+ if (/\/agent-token\/?$/.test(agentTokenUrl)) {
426
+ return agentTokenUrl.replace(/\/agent-token\/?$/, '/live/sandbox-pass');
427
+ }
428
+ return `${agentTokenUrl.replace(/\/+$/, '')}/live/sandbox-pass`;
429
+ }
386
430
  /** Ride the ALI-1757 session/pending-turn poll while one prompt is active.
387
431
  * This uses the same additive session GET contract, but deliberately shares
388
432
  * none of the between-turn credential cleanup or mint-on-401 behavior: the
@@ -396,20 +440,6 @@ export async function pollHostedSessionDuringTurn(opts) {
396
440
  };
397
441
  const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
398
442
  const pollMs = opts.pollMs ?? HOSTED_SESSION_DEFAULT_POLL_MS;
399
- const sleep = opts.sleep ?? ((ms) => new Promise(resolve => {
400
- if (opts.signal.aborted) {
401
- resolve();
402
- return;
403
- }
404
- const timer = setTimeout(finish, ms);
405
- const handleAbort = () => finish();
406
- function finish() {
407
- clearTimeout(timer);
408
- opts.signal.removeEventListener('abort', handleAbort);
409
- resolve();
410
- }
411
- opts.signal.addEventListener('abort', handleAbort, { once: true });
412
- }));
413
443
  while (!opts.signal.aborted) {
414
444
  try {
415
445
  const latest = await resolveSession({
@@ -444,8 +474,9 @@ export async function pollHostedSessionDuringTurn(opts) {
444
474
  // enter logs. A transient read failure cannot fail the running turn.
445
475
  diagnose('hosted interrupt poll failed; retrying');
446
476
  }
447
- if (!opts.signal.aborted)
448
- await waitForNextHostedSessionRead(sleep, pollMs);
477
+ if (!opts.signal.aborted) {
478
+ await waitForReadOrCheck({ signal: opts.signal, pollMs, sleep: opts.sleep, live: opts.live });
479
+ }
449
480
  }
450
481
  }
451
482
  /** Reuse an existing run or start one with the AGENT bearer (actor_type='agent'),
@@ -498,7 +529,7 @@ export async function resolveTeamRemote(opts) {
498
529
  const url = `${opts.baseUrl}/api/cli/workspaces/${encodeURIComponent(opts.workspaceId)}/repo-token`;
499
530
  const minted = await bearerJson(opts.fetchImpl, url, opts.bearer, {
500
531
  method: 'POST',
501
- headers: { 'Content-Type': 'application/json' },
532
+ headers: { 'Content-Type': 'application/json', 'X-Orizu-Session-Seed-Protocol': '1' },
502
533
  body: JSON.stringify({ purpose: 'team_read', sessionId: opts.sessionId }),
503
534
  });
504
535
  const remote = asString(minted.remote);
@@ -552,6 +583,30 @@ const defaultExec = (cmd, args, opts) => {
552
583
  const res = spawnSync(cmd, args, { cwd: opts?.cwd, encoding: 'utf8' });
553
584
  return { status: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
554
585
  };
586
+ /** ORI-2520: the repo on a woken disk must be this session's: a repository
587
+ * at the folder's top with the session's remote and a commit checked out
588
+ * (detached is fine). Which branch is checked out is not checked: each
589
+ * turn's save pushes the current commit to the session branch whatever is
590
+ * checked out, so an agent may have left another branch (or none) checked
591
+ * out. Returns that branch for the log. */
592
+ function assertWokenRepo(exec, workspaceDir, remote) {
593
+ const read = (...args) => {
594
+ const result = exec('git', ['-C', workspaceDir, ...args]);
595
+ return result.status === 0 ? result.stdout.trim() : null;
596
+ };
597
+ if (read('rev-parse', '--git-dir') !== '.git') {
598
+ throw new Error('woken disk: the repo folder holds no repository of its own');
599
+ }
600
+ // The configured value, not `remote get-url`, which applies url rewrites.
601
+ if (read('config', '--get', 'remote.origin.url') !== remote) {
602
+ throw new Error("woken disk: the repo's remote is not this session's");
603
+ }
604
+ // A clone cut off before any commit was checked out has no saved work.
605
+ if (read('rev-parse', '--verify', '--quiet', 'HEAD^{commit}') === null) {
606
+ throw new Error('woken disk: the repo has no commit checked out');
607
+ }
608
+ return read('rev-parse', '--abbrev-ref', 'HEAD') ?? 'no branch';
609
+ }
555
610
  /** Values interpolated into git's credential.helper string / clone args must be
556
611
  * benign. We pass everything via execFile-style arg arrays (no shell), but
557
612
  * still reject anything with newlines/control chars as defense in depth. */
@@ -588,6 +643,20 @@ class HostedTurnFailure extends Error {
588
643
  * provider fed by the same pull-mode source. Returns the loop's terminal status.
589
644
  */
590
645
  export async function runHostedBoot(opts) {
646
+ // ORI-2200: the sandbox's live socket, opened once the boot first waits for
647
+ // work and closed however the boot ends.
648
+ const liveChannels = [];
649
+ const idleWait = new AbortController();
650
+ try {
651
+ return await runHostedBootWithLive(opts, liveChannels, idleWait.signal);
652
+ }
653
+ finally {
654
+ idleWait.abort();
655
+ for (const channel of liveChannels)
656
+ channel.close();
657
+ }
658
+ }
659
+ async function runHostedBootWithLive(opts, liveChannels, idleWaitSignal) {
591
660
  const env = opts.env;
592
661
  const processEnv = opts.processEnv ?? process.env;
593
662
  const root = opts.root ?? process.cwd();
@@ -668,6 +737,22 @@ export async function runHostedBoot(opts) {
668
737
  attemptTimeoutMs: turnStatusAttemptTimeoutMs,
669
738
  });
670
739
  }
740
+ // The coordinator says "check now" over this socket when someone presses
741
+ // Stop or sends a message, so the waits below need not run their full
742
+ // interval. Best effort: without it the regular reads still run.
743
+ let liveChannel = null;
744
+ const openLive = () => {
745
+ if (!liveChannel) {
746
+ liveChannel = openHostedLiveChannel({
747
+ sandboxPassUrl: deriveLiveSandboxPassUrl(env.agentTokenUrl),
748
+ bootSecret: env.bootSecret,
749
+ fetchImpl,
750
+ log,
751
+ });
752
+ liveChannels.push(liveChannel);
753
+ }
754
+ return liveChannel;
755
+ };
671
756
  let consecutiveIdleSessionReadFailures = 0;
672
757
  const recordIdleSessionReadFailure = (detail) => {
673
758
  consecutiveIdleSessionReadFailures += 1;
@@ -677,7 +762,12 @@ export async function runHostedBoot(opts) {
677
762
  log(`idle session resolution failed; retrying (${detail})`);
678
763
  };
679
764
  while (!opts.runLoop && !currentNonTerminalRunId) {
680
- await sleep(Math.min(idlePollMs * 2 ** consecutiveIdleSessionReadFailures, idleReadBackoffCapMs));
765
+ await waitForReadOrCheck({
766
+ signal: idleWaitSignal,
767
+ pollMs: Math.min(idlePollMs * 2 ** consecutiveIdleSessionReadFailures, idleReadBackoffCapMs),
768
+ sleep: opts.sleep,
769
+ live: openLive(),
770
+ });
681
771
  try {
682
772
  if (bearer.expiresAtMs !== null && bearer.expiresAtMs <= now() + 60_000) {
683
773
  bearer = await pullAgentBearer({
@@ -786,54 +876,149 @@ export async function runHostedBoot(opts) {
786
876
  writeFile(bootContextAbs, serializeBootContext(bootContext));
787
877
  const helperValue = `!node ${helperScriptAbs} ${bootContextAbs}`;
788
878
  // 4b — ORI-2118: a hosted session's Artifacts copy is created empty (forks
789
- // of a busy team copy cannot be cloned), so fill it from the team copy
790
- // before the first clone. A copy that already has `main` is left alone.
879
+ // of a busy team copy cannot be cloned). Fill it from the server-selected
880
+ // origin before cloning. Selected saved history is checked on retries too.
881
+ if (session.seedOrigin === 'session' && !isValidCloudflareArtifactsGitRemote(repo.cloneUrl)) {
882
+ throw new Error('selected session source requires an admitted Artifacts destination');
883
+ }
791
884
  if (isValidCloudflareArtifactsGitRemote(repo.cloneUrl)) {
792
885
  const credentialConfig = (helper) => [`credential.helper=${helper}`, 'credential.useHttpPath=true'];
793
- await seedSessionCopy({
794
- exec,
795
- sessionRemote: repo.cloneUrl,
796
- sessionGitConfig: credentialConfig(helperValue),
797
- scratchDir: `${sessionDirAbs}/seed.git`,
798
- resolveTeam: async () => {
799
- const teamRemote = await resolveTeamRemote({ baseUrl: env.baseUrl, workspaceId, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
800
- assertSafeGitValue('teamRemote', teamRemote);
801
- const teamContextAbs = `${sessionDirAbs}/${TEAM_BOOT_CONTEXT_BASENAME}`;
802
- writeFile(teamContextAbs, serializeBootContext({
803
- ...bootContext,
804
- repoFullName: teamRemote,
805
- host: new URL(teamRemote).host.toLowerCase(),
806
- tokenPurposes: { primary: 'team_read', fallback: 'team_read' },
807
- }));
808
- return { remote: teamRemote, gitConfig: credentialConfig(`!node ${helperScriptAbs} ${teamContextAbs}`) };
809
- },
810
- });
886
+ const sourceRequestId = session.seedOrigin === 'session' ? randomUUID() : null;
887
+ const sourceCredentialFile = `${sessionDirAbs}/seed-source-credential`;
888
+ const sourceHelperFile = `${sessionDirAbs}/seed-source-helper.js`;
889
+ let seedFailed = false;
890
+ let seedError = null;
891
+ try {
892
+ await seedSessionCopy({
893
+ seedOrigin: session.seedOrigin,
894
+ exec,
895
+ sessionRemote: repo.cloneUrl,
896
+ sessionGitConfig: credentialConfig(helperValue),
897
+ scratchDir: `${sessionDirAbs}/seed.git`,
898
+ resolveTeam: async () => {
899
+ if (sourceRequestId) {
900
+ const url = `${env.baseUrl}/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`;
901
+ const credential = await bearerJson(fetchImpl, url, bearer.token, {
902
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
903
+ body: JSON.stringify({ purpose: 'source_read', sessionId: env.sessionId, requestId: sourceRequestId }),
904
+ });
905
+ const remote = asString(credential.remote);
906
+ const token = asString(credential.token);
907
+ const username = asString(credential.username);
908
+ if (credential.provider !== 'cloudflare_artifacts' || !remote || !isValidCloudflareArtifactsGitRemote(remote) || !token || !username || credential.requestId !== sourceRequestId) {
909
+ throw new Error('source_read response carried no valid admitted source credential');
910
+ }
911
+ assertSafeGitValue('sourceRemote', remote);
912
+ writeSecretFile(sourceCredentialFile, JSON.stringify({ remote, username, token }), writeFile);
913
+ writeFile(sourceHelperFile, String.raw `const fs = require('fs');
914
+ const c = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
915
+ let input = '';
916
+ process.stdin.on('data', d => input += d);
917
+ process.stdin.on('end', () => {
918
+ const fields = Object.fromEntries(input.trim().split('\n').map(line => {
919
+ const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)];
920
+ }));
921
+ const u = new URL(c.remote);
922
+ if (process.argv[3] === 'get' && fields.protocol === 'https' && fields.host === u.host && fields.path === u.pathname.slice(1)) {
923
+ process.stdout.write('username=' + c.username + '\npassword=' + c.token + '\n\n');
924
+ }
925
+ });`);
926
+ return { remote, gitConfig: ['credential.helper=', `credential.helper=!node ${sourceHelperFile} ${sourceCredentialFile}`, 'credential.useHttpPath=true'] };
927
+ }
928
+ const teamRemote = await resolveTeamRemote({ baseUrl: env.baseUrl, workspaceId, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
929
+ assertSafeGitValue('teamRemote', teamRemote);
930
+ const teamContextAbs = `${sessionDirAbs}/${TEAM_BOOT_CONTEXT_BASENAME}`;
931
+ writeFile(teamContextAbs, serializeBootContext({
932
+ ...bootContext,
933
+ repoFullName: teamRemote,
934
+ host: new URL(teamRemote).host.toLowerCase(),
935
+ tokenPurposes: { primary: 'team_read', fallback: 'team_read' },
936
+ }));
937
+ return { remote: teamRemote, gitConfig: credentialConfig(`!node ${helperScriptAbs} ${teamContextAbs}`) };
938
+ },
939
+ });
940
+ }
941
+ catch (error) {
942
+ seedFailed = true;
943
+ seedError = error;
944
+ }
945
+ // Always request cleanup, including lost mint replies. The server tracks
946
+ // the request before issuance and refuses uncertain cleanup.
947
+ let cleanupFailed = false;
948
+ let cleanupError = null;
949
+ try {
950
+ try {
951
+ if (sourceRequestId) {
952
+ const cleanup = await bearerJson(fetchImpl, `${env.baseUrl}/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, bearer.token, {
953
+ method: 'DELETE', headers: { 'Content-Type': 'application/json' },
954
+ body: JSON.stringify({ purpose: 'source_read', sessionId: env.sessionId, requestId: sourceRequestId }),
955
+ });
956
+ if (cleanup.settled !== true || cleanup.outcome !== 'revoked') {
957
+ throw new Error('temporary source credential revocation could not be proved');
958
+ }
959
+ }
960
+ }
961
+ finally {
962
+ if (sourceRequestId) {
963
+ const removed = exec('rm', ['-f', '--', sourceCredentialFile, sourceHelperFile]);
964
+ if (removed.status !== 0)
965
+ throw new Error('temporary source credential cleanup failed');
966
+ }
967
+ }
968
+ }
969
+ catch (error) {
970
+ cleanupFailed = true;
971
+ cleanupError = error;
972
+ }
973
+ // A failed copy keeps its own reason; a cleanup failure is added, never
974
+ // substituted, so the person sees why the copy stopped.
975
+ if (seedFailed && cleanupFailed) {
976
+ const reason = (error) => error instanceof Error ? error.message : String(error);
977
+ throw new Error(`${reason(seedError)}; temporary source credential cleanup also failed: ${reason(cleanupError)}`);
978
+ }
979
+ if (seedFailed)
980
+ throw seedError;
981
+ if (cleanupFailed)
982
+ throw cleanupError;
811
983
  }
812
984
  // 5 — Clone the session branch VIA the pull-mode credential helper (same
813
985
  // invocation the operator path makes; no token in the URL/config).
814
- const clone = exec('git', [
815
- 'clone',
816
- '--depth',
817
- '1',
818
- '--branch',
819
- session.repoBranch,
820
- '-c',
821
- `credential.helper=${helperValue}`,
822
- '-c',
823
- 'credential.useHttpPath=true',
824
- repo.cloneUrl,
825
- workspaceDir,
826
- ]);
827
- if (clone.status !== 0) {
828
- const detail = (clone.stderr || clone.stdout || `exit ${clone.status}`).trim();
829
- throw new Error(`git clone failed: ${detail}`);
986
+ // ORI-2520: a woken sandbox boots on the disk it slept with, so this
987
+ // session's repo is already here with any work since the last push. It is
988
+ // reused; anything else in that folder is refused, never overwritten. An
989
+ // empty folder is a fresh disk (the runtime image ships one) and is cloned
990
+ // into.
991
+ const wokenDisk = existsSync(workspaceDir) && readdirSync(workspaceDir).length > 0;
992
+ if (wokenDisk) {
993
+ const checkedOut = assertWokenRepo(exec, workspaceDir, repo.cloneUrl);
994
+ log(`woken disk: reusing the repo (checked out: ${checkedOut})`);
995
+ }
996
+ else {
997
+ const clone = exec('git', [
998
+ 'clone',
999
+ '--depth',
1000
+ '1',
1001
+ '--branch',
1002
+ session.repoBranch,
1003
+ '-c',
1004
+ `credential.helper=${helperValue}`,
1005
+ '-c',
1006
+ 'credential.useHttpPath=true',
1007
+ repo.cloneUrl,
1008
+ workspaceDir,
1009
+ ]);
1010
+ if (clone.status !== 0) {
1011
+ const detail = (clone.stderr || clone.stdout || `exit ${clone.status}`).trim();
1012
+ throw new Error(`git clone failed: ${detail}`);
1013
+ }
830
1014
  }
831
1015
  // Persist the helper + agent identity repo-LOCAL for subsequent fetch/push.
832
1016
  exec('git', ['-C', workspaceDir, 'config', 'credential.helper', helperValue]);
833
1017
  exec('git', ['-C', workspaceDir, 'config', 'credential.useHttpPath', 'true']);
834
1018
  exec('git', ['-C', workspaceDir, 'config', 'user.name', AGENT_GIT_IDENTITY.name]);
835
1019
  exec('git', ['-C', workspaceDir, 'config', 'user.email', AGENT_GIT_IDENTITY.email]);
836
- log(`cloned ${session.repoBranch}`);
1020
+ if (!wokenDisk)
1021
+ log(`cloned ${session.repoBranch}`);
837
1022
  // 5b — Stage the orizu skill into the cloned repo so the agent discovers the
838
1023
  // Orizu workflows (ALI-1059). SHARED with the operator path via `stageOrizuSkill`
839
1024
  // (one resolution chain + the harvest-safe .git/info/exclude append). Non-fatal:
@@ -953,6 +1138,7 @@ export async function runHostedBoot(opts) {
953
1138
  let currentBearer = bearer;
954
1139
  let lastResult = { status: 'succeeded', agentSessionId: session.agentSessionId, installOk: true, error: null };
955
1140
  let hostedTurnFailure = null;
1141
+ const live = openLive();
956
1142
  try {
957
1143
  for (;;) {
958
1144
  assertSafeRunId(current.runId);
@@ -996,6 +1182,7 @@ export async function runHostedBoot(opts) {
996
1182
  onDiagnostic: log,
997
1183
  readFile,
998
1184
  pollMs: idlePollMs,
1185
+ live,
999
1186
  });
1000
1187
  try {
1001
1188
  lastResult = await runHostedLoopTurn(hostedLoopSession, {
@@ -1054,8 +1241,14 @@ export async function runHostedBoot(opts) {
1054
1241
  // budget. Refresh proactively near expiry and once on a 401. Exhausting
1055
1242
  // the bounded agent-token pull is fatal and reported for the current run;
1056
1243
  // other transient status-read failures retain the session and back off.
1244
+ // ORI-2517: the first read after a turn runs at once. A message the
1245
+ // person sent during the turn or its save, or a check that came in
1246
+ // meanwhile, is already waiting.
1247
+ let isFirstIdleRead = true;
1057
1248
  for (;;) {
1058
- await waitForNextHostedSessionRead(sleep, idlePollMs);
1249
+ if (!isFirstIdleRead)
1250
+ await waitForReadOrCheck({ signal: idleWaitSignal, pollMs: idlePollMs, sleep: opts.sleep, live });
1251
+ isFirstIdleRead = false;
1059
1252
  let pendingTurn = null;
1060
1253
  try {
1061
1254
  if (currentBearer.expiresAtMs !== null && currentBearer.expiresAtMs <= now() + 60_000) {
@@ -73,6 +73,7 @@ export declare const HARNESS_EVENT_KINDS: {
73
73
  readonly session_title: "session_title";
74
74
  readonly egress_blocked: "egress_blocked";
75
75
  readonly egress_allowed: "egress_allowed";
76
+ readonly work_saving: "work_saving";
76
77
  readonly work_persisted: "work_persisted";
77
78
  readonly work_none: "work_none";
78
79
  readonly work_not_inspected: "work_not_inspected";
@@ -1 +1 @@
1
- {"version":3,"file":"hosted-harness.d.ts","sourceRoot":"","sources":["../src/hosted-harness.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hosted-harness.d.ts","sourceRoot":"","sources":["../src/hosted-harness.ts"],"names":[],"mappings":"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"}
@@ -51,6 +51,8 @@ export const HARNESS_EVENT_KINDS = {
51
51
  // the end-of-run auto-harvest result (persisted/none/not-inspected/failed)
52
52
  // and the headless question auto-handling record (question_auto_answered). They
53
53
  // are ordinary structural events (never terminal), appended like any other.
54
+ // work_saving (ORI-2517) marks the model done and the save starting.
55
+ work_saving: 'work_saving',
54
56
  work_persisted: 'work_persisted',
55
57
  work_none: 'work_none',
56
58
  work_not_inspected: 'work_not_inspected',
@@ -8,7 +8,8 @@
8
8
  * 1. `git status --porcelain` (plumbing) decides if the clone is dirty. Ignored
9
9
  * files never appear in porcelain output, so a checkpoint is skipped when the
10
10
  * only changes are ignored files.
11
- * 2. If dirty: `git add -A` then `git commit` attributed to the agent identity
11
+ * 2. If dirty: `git add -A`, unstage the staged Orizu skill links
12
+ * (orizu-skill-links.ts), and if anything is still staged, `git commit` attributed to the agent identity
12
13
  * (AGENT_GIT_IDENTITY) with message `checkpoint: run <runId> auto-harvest`.
13
14
  * 3. If there is anything unpushed (a fresh checkpoint OR pre-existing local
14
15
  * commits the agent made but did not push), push the exact HEAD SHA to the
@@ -1 +1 @@
1
- {"version":3,"file":"hosted-harvest.d.ts","sourceRoot":"","sources":["../src/hosted-harvest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAaH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iFAAiF;AACjF,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,iBAAiB,CAAA;AAExE,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,YAAY,EAAE,MAAM,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,aAAa,EAAE,MAAM,CAAA;IACrB,+DAA+D;IAC/D,gBAAgB,EAAE,MAAM,CAAA;IACxB,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,0EAA0E;IAC1E,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;IACvC,kFAAkF;IAClF,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACtC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,qEAAqE;IACrE,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAElD,eAAO,MAAM,mCAAmC,0GAItC,CAAA;AAEV,eAAO,MAAM,iCAAiC,+EAGpC,CAAA;AAEV,eAAO,MAAM,+BAA+B;;;;CAgBlC,CAAA;AAUV,wBAAgB,4BAA4B,CAC1C,UAAU,GAAE,SAAS,MAAM,EAAwC,GAClE,MAAM,GAAG,IAAI,CAEf;AA6GD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CAmKrE"}
1
+ {"version":3,"file":"hosted-harvest.d.ts","sourceRoot":"","sources":["../src/hosted-harvest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAcH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iFAAiF;AACjF,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,iBAAiB,CAAA;AAExE,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,YAAY,EAAE,MAAM,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,aAAa,EAAE,MAAM,CAAA;IACrB,+DAA+D;IAC/D,gBAAgB,EAAE,MAAM,CAAA;IACxB,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,0EAA0E;IAC1E,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;IACvC,kFAAkF;IAClF,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACtC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,qEAAqE;IACrE,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAElD,eAAO,MAAM,mCAAmC,0GAItC,CAAA;AAEV,eAAO,MAAM,iCAAiC,+EAGpC,CAAA;AAEV,eAAO,MAAM,+BAA+B;;;;CAgBlC,CAAA;AAUV,wBAAgB,4BAA4B,CAC1C,UAAU,GAAE,SAAS,MAAM,EAAwC,GAClE,MAAM,GAAG,IAAI,CAEf;AA6GD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CA8KrE"}
@@ -8,7 +8,8 @@
8
8
  * 1. `git status --porcelain` (plumbing) decides if the clone is dirty. Ignored
9
9
  * files never appear in porcelain output, so a checkpoint is skipped when the
10
10
  * only changes are ignored files.
11
- * 2. If dirty: `git add -A` then `git commit` attributed to the agent identity
11
+ * 2. If dirty: `git add -A`, unstage the staged Orizu skill links
12
+ * (orizu-skill-links.ts), and if anything is still staged, `git commit` attributed to the agent identity
12
13
  * (AGENT_GIT_IDENTITY) with message `checkpoint: run <runId> auto-harvest`.
13
14
  * 3. If there is anything unpushed (a fresh checkpoint OR pre-existing local
14
15
  * commits the agent made but did not push), push the exact HEAD SHA to the
@@ -30,6 +31,7 @@ import { mkdtempSync, readdirSync, rmSync, statSync } from 'fs';
30
31
  import { tmpdir } from 'os';
31
32
  import { join } from 'path';
32
33
  import { isValidCloudflareArtifactsGitRemote, parseCanonicalCredentialFreeHttpsUrl, } from './cloudflare-artifacts-git-remote.js';
34
+ import { hasStagedChanges, NOTHING_STAGED_ERROR, unstageOrizuSkillLinks } from './orizu-skill-links.js';
33
35
  export const HOSTED_HARVEST_CA_BUNDLE_CANDIDATES = [
34
36
  '/etc/pki/tls/certs/ca-bundle.crt',
35
37
  '/etc/ssl/certs/ca-certificates.crt',
@@ -224,31 +226,41 @@ export function harvestWorkspace(opts) {
224
226
  return { kind: 'work_persist_failed', error: 'hosted_harvest_ca_path_policy_ineffective' };
225
227
  }
226
228
  }
227
- // Never harvest bootstrap-injected runtime scaffolding (ALI-1051): the
228
- // .claude/skills symlink is a sandbox-local pointer, not the agent's work.
229
- // Bootstrap also excludes it via .git/info/exclude; this pathspec is the
230
- // belt-and-braces for any sandbox where that didn't run.
231
- const excludeScaffold = [':(exclude).claude/skills/**', ':(exclude).claude/skills'];
232
- const status = exec(['status', '--porcelain', '--', '.', ...excludeScaffold]);
229
+ // `git status` is only the cheap first look; whether there is anything to
230
+ // save is decided from the index after the staged skill links are taken
231
+ // back out (ALI-1051, ORI-2442). A sandbox where the ignore lines did not
232
+ // run shows the links here and still saves nothing.
233
+ const status = exec(['status', '--porcelain']);
233
234
  if (status.exitCode !== 0) {
234
235
  return { kind: 'work_persist_failed', error: `git status failed: ${detail(status)}` };
235
236
  }
236
- const dirty = status.stdout.trim().length > 0;
237
+ let dirty = status.stdout.trim().length > 0;
237
238
  if (dirty) {
238
- // Add everything, then unstage the staged skill link. An exclude
239
- // pathspec on add makes git exit 1 once bootstrap has ignored
240
- // .claude/skills ("paths are ignored"), which is every hosted session
241
- // (ORI-2441, git 2.49 in the sandbox). Only the link's own path is reset,
242
- // so an agent's edits to a team's tracked skills next to it are saved;
243
- // reset, unlike rm --cached, never deletes a tracked file.
239
+ // Add everything, then unstage the staged skill links. An exclude
240
+ // pathspec on add makes git exit 1 for ignored paths (ORI-2441, git 2.49
241
+ // in the sandbox). Only the links themselves are reset, so an agent's
242
+ // edits to a team's own skills next to them are saved; reset, unlike
243
+ // rm --cached, never deletes a tracked file.
244
244
  const add = exec(['add', '-A', '--', '.']);
245
245
  if (add.exitCode !== 0) {
246
246
  return { kind: 'work_persist_failed', error: `git add failed: ${detail(add)}` };
247
247
  }
248
- const unstage = exec(['reset', '-q', '--', '.claude/skills/orizu']);
249
- if (unstage.exitCode !== 0) {
250
- return { kind: 'work_persist_failed', error: `git reset of .claude/skills/orizu failed: ${detail(unstage)}` };
248
+ const unstage = unstageOrizuSkillLinks(exec);
249
+ if (!unstage.ok) {
250
+ return { kind: 'work_persist_failed', error: unstage.error };
251
251
  }
252
+ const staged = hasStagedChanges(exec);
253
+ if (typeof staged !== 'boolean') {
254
+ return { kind: 'work_persist_failed', error: staged.error };
255
+ }
256
+ // Dirty with nothing staged and no link taken out means the work cannot
257
+ // be committed here; say so rather than report nothing to save.
258
+ if (!staged && unstage.unstaged.length === 0) {
259
+ return { kind: 'work_persist_failed', error: NOTHING_STAGED_ERROR };
260
+ }
261
+ dirty = staged;
262
+ }
263
+ if (dirty) {
252
264
  const commit = exec([
253
265
  '-c',
254
266
  `user.name=${opts.author.name}`,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ORI-2200 slice 2 (ADR-040): the sandbox's socket to its session coordinator.
3
+ * The coordinator sends "check now" when someone presses Stop; the sandbox
4
+ * then reads its session at once instead of waiting for the next regular read.
5
+ * The message carries no instruction of its own: the session record decides
6
+ * whether this run was interrupted, so a forged or stray check costs one read.
7
+ *
8
+ * Best effort. If the socket cannot open (an older coordinator, a network
9
+ * rule), the regular read every few seconds still catches the Stop.
10
+ */
11
+ export interface HostedLiveChannel {
12
+ /** Resolves on the next "check now" (or at once if one arrived since the last call), or when `signal` aborts. */
13
+ nextCheck(signal: AbortSignal): Promise<void>;
14
+ /** How many checks have arrived so far, read or not. */
15
+ checkCount(): number;
16
+ close(): void;
17
+ }
18
+ export declare function openHostedLiveChannel(opts: {
19
+ /** `<coordinator>/sessions/<id>/live/sandbox-pass`. */
20
+ sandboxPassUrl: string;
21
+ bootSecret: string;
22
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
23
+ log?: (line: string) => void;
24
+ }): HostedLiveChannel;
25
+ //# sourceMappingURL=hosted-live-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosted-live-channel.d.ts","sourceRoot":"","sources":["../src/hosted-live-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAQH,MAAM,WAAW,iBAAiB;IAChC,iHAAiH;IACjH,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,wDAAwD;IACxD,UAAU,IAAI,MAAM,CAAA;IACpB,KAAK,IAAI,IAAI,CAAA;CACd;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAC1C,uDAAuD;IACvD,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAClE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC7B,GAAG,iBAAiB,CAwGpB"}