orizu 0.5.43 → 0.5.45

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 (50) hide show
  1. package/dist/hosted-agent-prompt.d.ts +6 -0
  2. package/dist/hosted-agent-prompt.d.ts.map +1 -1
  3. package/dist/hosted-agent-prompt.js +11 -0
  4. package/dist/hosted-boot.d.ts +3 -0
  5. package/dist/hosted-boot.d.ts.map +1 -1
  6. package/dist/hosted-boot.js +14 -0
  7. package/dist/hosted-bootstrap-rehearsal.d.ts +2 -0
  8. package/dist/hosted-bootstrap-rehearsal.d.ts.map +1 -1
  9. package/dist/hosted-bootstrap-rehearsal.js +5 -0
  10. package/dist/hosted-broker-server.d.ts +2 -0
  11. package/dist/hosted-broker-server.d.ts.map +1 -1
  12. package/dist/hosted-broker-server.js +4 -1
  13. package/dist/hosted-harness-opencode.d.ts +2 -0
  14. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  15. package/dist/hosted-harness-opencode.js +2 -2
  16. package/dist/hosted-loop-lifecycle.d.ts +2 -0
  17. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  18. package/dist/hosted-loop.d.ts.map +1 -1
  19. package/dist/hosted-loop.js +3 -0
  20. package/dist/hosted-project-folder-seed.d.ts +18 -0
  21. package/dist/hosted-project-folder-seed.d.ts.map +1 -0
  22. package/dist/hosted-project-folder-seed.js +78 -0
  23. package/dist/hosted-session-cli.d.ts.map +1 -1
  24. package/dist/hosted-session-cli.js +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -8
  27. package/dist/score-save-workflow.d.ts +10 -14
  28. package/dist/score-save-workflow.d.ts.map +1 -1
  29. package/dist/score-save-workflow.js +14 -11
  30. package/dist/secret-redaction.d.ts +14 -0
  31. package/dist/secret-redaction.d.ts.map +1 -1
  32. package/dist/secret-redaction.js +25 -0
  33. package/dist/skilled-proposer-launch.d.ts +5 -0
  34. package/dist/skilled-proposer-launch.d.ts.map +1 -1
  35. package/dist/skilled-proposer-launch.js +104 -46
  36. package/package.json +1 -1
  37. package/src/hosted-agent-prompt.ts +11 -0
  38. package/src/hosted-boot.ts +18 -0
  39. package/src/hosted-bootstrap-rehearsal.ts +6 -0
  40. package/src/hosted-broker-server.ts +5 -1
  41. package/src/hosted-harness-opencode.ts +4 -2
  42. package/src/hosted-loop-lifecycle.ts +2 -0
  43. package/src/hosted-loop.ts +2 -0
  44. package/src/hosted-project-folder-seed.ts +95 -0
  45. package/src/hosted-session-cli.ts +3 -1
  46. package/src/index.ts +9 -11
  47. package/src/score-save-workflow.ts +21 -16
  48. package/src/secret-redaction.ts +30 -0
  49. package/src/skilled-proposer-launch.ts +78 -36
  50. package/vendor/orizu-gepa/src/orizu_gepa_connector/runtime.py +123 -0
@@ -4,16 +4,19 @@ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js';
4
4
  import { extractErrorMessage } from './error-response.js';
5
5
  import { appErrorOf, unreachedCode } from './request-outcome.js';
6
6
  // Loaded only through `await import(...)`, like the dataset workflows.
7
+ const SavedScore = Schema.Struct({ scoreRun: Schema.Struct({ id: Schema.NonEmptyString }) });
8
+ const SavedRun = Schema.Struct({ run: Schema.Struct({ id: Schema.NonEmptyString }) });
9
+ // `serverMayBeSaved` is what the route says when it cannot tell whether the run was saved.
7
10
  const COMMANDS = {
8
- submit: { label: 'Score submit', failed: 'Failed to submit score' },
9
- exec: { label: 'Scorer exec', failed: 'Failed to execute scorer' },
11
+ submit: { label: 'Score submit', failed: 'Failed to submit score', serverMayBeSaved: 'Score run may already be saved', saved: SavedScore },
12
+ exec: { label: 'Scorer exec', failed: 'Failed to execute scorer', serverMayBeSaved: 'Score run may already be saved', saved: SavedScore },
13
+ run: { label: 'Run submit', failed: 'Failed to submit run', serverMayBeSaved: 'Run may already be saved', saved: SavedRun },
10
14
  };
11
- // What the server says when it cannot tell whether the run was saved.
12
- const SERVER_MAY_BE_SAVED = 'Score run may already be saved';
13
15
  // Node's fetch publishes this once a request's whole body has been written.
14
16
  const BODY_SENT = 'undici:request:bodySent';
15
- const SavedReply = Schema.Struct({ scoreRun: Schema.Struct({ id: Schema.NonEmptyString }) });
16
- const scoreMayBeSaved = (save) => `The score may already be saved: run \`orizu scorers detail ${save.scorerVersionId} --project ${save.project}\` and check its newest score runs before running this again.`;
17
+ const mayBeSaved = (save) => save.command === 'run'
18
+ ? `The run may already be saved: run \`orizu runs list --project ${save.project} --prompt-version ${save.promptVersionId}\` and check its newest runs before running this again.`
19
+ : `The score may already be saved: run \`orizu scorers detail ${save.scorerVersionId} --project ${save.project}\` and check its newest score runs before running this again.`;
17
20
  // The run may be saved when the server says so, when the reply was lost, late
18
21
  // or unreadable after the request was sent, when a 5xx did not come from the
19
22
  // app (a gateway giving up while the app kept working), or when the reply has
@@ -30,7 +33,7 @@ class ScoreSaveFailure extends Error {
30
33
  // second run. The deadline starts once the whole body has been sent, so a
31
34
  // large upload on a slow link takes as long as it did before.
32
35
  function scoreSave(save, request, timeoutMs) {
33
- const { label, failed } = COMMANDS[save.command];
36
+ const { label, failed, serverMayBeSaved, saved } = COMMANDS[save.command];
34
37
  return Effect.suspend(() => {
35
38
  let sent = false;
36
39
  let timedOut = false;
@@ -62,7 +65,7 @@ function scoreSave(save, request, timeoutMs) {
62
65
  if (!response.ok) {
63
66
  const appError = await appErrorOf(response.clone());
64
67
  const detail = await extractErrorMessage(response);
65
- if (appError === SERVER_MAY_BE_SAVED)
68
+ if (appError === serverMayBeSaved)
66
69
  throw new ScoreSaveFailure(appError, true);
67
70
  if (response.status < 400 || (response.status >= 500 && appError === null)) {
68
71
  throw new ScoreSaveFailure(`${label} got no clear answer (status ${response.status}).`, true);
@@ -79,7 +82,7 @@ function scoreSave(save, request, timeoutMs) {
79
82
  data = undefined;
80
83
  }
81
84
  const usable = save.saves
82
- ? Result.isSuccess(Schema.decodeUnknownResult(SavedReply)(data))
85
+ ? Result.isSuccess(Schema.decodeUnknownResult(saved)(data))
83
86
  : typeof data === 'object' && data !== null && !Array.isArray(data);
84
87
  if (!usable) {
85
88
  throw new ScoreSaveFailure(`${label} returned an unexpected response (status ${response.status}).`, true);
@@ -111,9 +114,9 @@ function scoreSave(save, request, timeoutMs) {
111
114
  });
112
115
  });
113
116
  }
114
- /** `orizu scores submit` and `orizu scorers exec`: the reply, or an error saying whether the score may be saved. */
117
+ /** `orizu scores submit`, `orizu scorers exec` and `orizu runs submit`: the reply, or an error saying whether the save may have happened. */
115
118
  export async function sendScoreSave(save, request) {
116
- const exit = await Effect.runPromiseExit(scoreSave(save, request, datasetTransferTimeoutMs()).pipe(Effect.mapError(failure => new Error(failure.mayBeSaved && save.saves ? `${failure.message}\n${scoreMayBeSaved(save)}` : failure.message))));
119
+ const exit = await Effect.runPromiseExit(scoreSave(save, request, datasetTransferTimeoutMs()).pipe(Effect.mapError(failure => new Error(failure.mayBeSaved && save.saves ? `${failure.message}\n${mayBeSaved(save)}` : failure.message))));
117
120
  if (Exit.isSuccess(exit))
118
121
  return exit.value;
119
122
  throw Cause.squash(exit.cause);
@@ -28,6 +28,20 @@
28
28
  * event payload can be passed through before it is sent to the RunAPI.
29
29
  */
30
30
  export declare const REDACTION_PLACEHOLDER = "[redacted]";
31
+ /**
32
+ * The shape pass alone, for servers that hold no secret values: the web app
33
+ * and the session coordinator scrub agent text with it before it reaches a
34
+ * browser (ORI-1905). One list of shapes for the CLI and both servers.
35
+ */
36
+ export declare function redactSecretShapes(input: string): string;
37
+ /**
38
+ * For reply text shown as it is written: each snapshot is the reply so far, so
39
+ * one can end part-way through a token, before it is long enough for its shape
40
+ * to match. Such a tail is redacted too. The saved read shows a reply's last
41
+ * snapshot, so a finished reply that really ends in a bare `sk-` loses those
42
+ * characters there as well.
43
+ */
44
+ export declare function redactStreamedTextShapes(input: string): string;
31
45
  export interface RedactOptions {
32
46
  /** Verbatim secret values to strip wherever they appear (bearer, tokens). */
33
47
  secrets?: readonly string[];
@@ -1 +1 @@
1
- {"version":3,"file":"secret-redaction.d.ts","sourceRoot":"","sources":["../src/secret-redaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,eAAO,MAAM,qBAAqB,eAAe,CAAA;AAgDjD,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,CAAC,CAGzE;AAmBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAQjF"}
1
+ {"version":3,"file":"secret-redaction.d.ts","sourceRoot":"","sources":["../src/secret-redaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,eAAO,MAAM,qBAAqB,eAAe,CAAA;AAgDjD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAExD;AAUD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,CAAC,CAGzE;AAmBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAQjF"}
@@ -70,6 +70,31 @@ function redactString(input, secrets) {
70
70
  }
71
71
  return out;
72
72
  }
73
+ /**
74
+ * The shape pass alone, for servers that hold no secret values: the web app
75
+ * and the session coordinator scrub agent text with it before it reaches a
76
+ * browser (ORI-1905). One list of shapes for the CLI and both servers.
77
+ */
78
+ export function redactSecretShapes(input) {
79
+ return redactString(input, []);
80
+ }
81
+ // A known credential prefix at the end of the text, followed by fewer
82
+ // characters than its shape above needs: the start of a token still being
83
+ // written. Each run is bounded, so the scan stays linear in the text, and a
84
+ // longer run that is not a token (a path, a word) is left alone. `Bearer` only
85
+ // counts in an Authorization header, so prose like "a Bearer token" keeps its
86
+ // last word.
87
+ const OPEN_TOKEN_AT_END = /(?:\bgithub_pat_[A-Za-z0-9_]{0,19}|\bgh[posur]_[A-Za-z0-9_]{0,19}|\borizu_(?:agent|pat)_[A-Za-z0-9_-]{0,9}|\bsk-[A-Za-z0-9_-]{0,19}|\b(authorization:\s*bearer)\s+[A-Za-z0-9._~+/-]{0,15})$/i;
88
+ /**
89
+ * For reply text shown as it is written: each snapshot is the reply so far, so
90
+ * one can end part-way through a token, before it is long enough for its shape
91
+ * to match. Such a tail is redacted too. The saved read shows a reply's last
92
+ * snapshot, so a finished reply that really ends in a bare `sk-` loses those
93
+ * characters there as well.
94
+ */
95
+ export function redactStreamedTextShapes(input) {
96
+ return redactSecretShapes(input).replace(OPEN_TOKEN_AT_END, (_match, header) => header ? `${header} ${REDACTION_PLACEHOLDER}` : REDACTION_PLACEHOLDER);
97
+ }
73
98
  /**
74
99
  * Deep-redact a JSON-ish value. Strings are scrubbed; arrays/objects are walked;
75
100
  * everything else is returned as-is. Object KEYS are never treated as secrets
@@ -23,6 +23,11 @@ export interface SkilledProposerChildOutput {
23
23
  stdout: (chunk: string) => void;
24
24
  stderr: (chunk: string) => void;
25
25
  }
26
+ /**
27
+ * Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
28
+ * waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
29
+ * it) and then ends by that same signal.
30
+ */
26
31
  export declare function spawnSkilledProposerChild(command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv, onSpawn?: () => Promise<void>, output?: SkilledProposerChildOutput): Promise<{
27
32
  status: number | null;
28
33
  error?: Error;
@@ -1 +1 @@
1
- {"version":3,"file":"skilled-proposer-launch.d.ts","sourceRoot":"","sources":["../src/skilled-proposer-launch.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAO3D,UAAU,yBAAyB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAA;IAC3B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAClD,oBAAoB,EAAE,OAAO,CAAA;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAClC;AACD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAC,UAAU,CAAA;IAC9B,MAAM,CAAC,EAAE,yBAAyB,CAAA;CACnC;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAiBD,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,CAAC,UAAU,EACnF,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,MAAM,CAAC,EAAE,0BAA0B;;;GA4DpC;AAwCD,UAAU,aAAa;IAAG,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE;AAQ9F,wBAAgB,0BAA0B,CAAC,EAAE,EAAE,aAAa,EAAE,MAAM,UAAQ,GAAG,MAAM,CAepF;AAED,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,MAAM,CAAC,UAAU,GAC7B,qBAAqB,CAyBvB"}
1
+ {"version":3,"file":"skilled-proposer-launch.d.ts","sourceRoot":"","sources":["../src/skilled-proposer-launch.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAO3D,UAAU,yBAAyB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAA;IAC3B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAClD,oBAAoB,EAAE,OAAO,CAAA;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAClC;AACD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAC,UAAU,CAAA;IAC9B,MAAM,CAAC,EAAE,yBAAyB,CAAA;CACnC;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAsBD;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,CAAC,UAAU,EACnF,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,MAAM,CAAC,EAAE,0BAA0B;YA0CX,MAAM,GAAG,IAAI;YAAU,KAAK;GAkDrD;AAwCD,UAAU,aAAa;IAAG,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE;AAQ9F,wBAAgB,0BAA0B,CAAC,EAAE,EAAE,aAAa,EAAE,MAAM,UAAQ,GAAG,MAAM,CAepF;AAED,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,MAAM,CAAC,UAAU,GAC7B,qBAAqB,CAyBvB"}
@@ -23,70 +23,128 @@ function materializeConfigPayload(engine, environment) {
23
23
  }
24
24
  return () => rmSync(root, { recursive: true, force: true });
25
25
  }
26
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'];
27
+ // The optimizer marks its run stopped before exiting (at most 10 s for the
28
+ // status write); past this it is killed so nothing is left running (ORI-2402).
29
+ const CHILD_STOP_WAIT_MS = 15_000;
30
+ /**
31
+ * Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
32
+ * waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
33
+ * it) and then ends by that same signal.
34
+ */
26
35
  export async function spawnSkilledProposerChild(command, args, engine, environment, onSpawn, output) {
27
36
  const childEnvironment = { ...environment };
28
- const shouldMaterialize = engine === 'official'
29
- && childEnvironment.ORIZU_CANDIDATE_PROPOSER === 'skilled-proposer'
30
- && childEnvironment.ORIZU_SKILLED_PROPOSER_CONFIG !== undefined;
31
- if (!shouldMaterialize)
32
- return spawnSync(command, args, { stdio: 'inherit', env: childEnvironment });
33
37
  let child;
34
38
  let cleanup = () => { };
35
- const signals = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'];
36
- const handlers = signals.map(signal => ({
37
- signal,
38
- handle: () => {
39
+ let stopSignal;
40
+ // On the terminal path the optimizer leads its own process group, so a stop
41
+ // also reaches its runners, which may run in worker threads it can't stop.
42
+ let childGroup;
43
+ const forward = (signal) => {
44
+ if (childGroup) {
39
45
  try {
40
- cleanup();
46
+ process.kill(-childGroup, signal);
47
+ return;
41
48
  }
42
- finally {
43
- try {
44
- child?.kill(signal);
45
- }
46
- finally {
47
- for (const entry of handlers)
48
- process.off(entry.signal, entry.handle);
49
- process.kill(process.pid, signal);
50
- }
49
+ catch { /* group already gone */ }
50
+ }
51
+ try {
52
+ child?.kill(signal);
53
+ }
54
+ catch { /* already exited */ }
55
+ };
56
+ let giveUp;
57
+ const removeHandlers = () => { for (const entry of handlers)
58
+ process.off(entry.signal, entry.handle); };
59
+ const endBySignal = (signal) => {
60
+ if (giveUp)
61
+ clearTimeout(giveUp);
62
+ removeHandlers();
63
+ cleanup();
64
+ // With our handlers gone, the signal's default action ends the process.
65
+ process.kill(process.pid, signal);
66
+ return new Promise(() => { });
67
+ };
68
+ const handlers = STOP_SIGNALS.map(signal => ({
69
+ signal,
70
+ handle: () => {
71
+ if (!child) {
72
+ void endBySignal(signal);
73
+ return;
51
74
  }
75
+ forward(signal);
76
+ if (stopSignal)
77
+ return;
78
+ stopSignal = signal;
79
+ // The child read the private payload at start and a stopping run
80
+ // proposes nothing, so it goes now rather than when the child exits.
81
+ cleanup();
82
+ giveUp = setTimeout(() => forward('SIGKILL'), CHILD_STOP_WAIT_MS);
52
83
  },
53
84
  }));
54
85
  for (const entry of handlers)
55
- process.once(entry.signal, entry.handle);
86
+ process.on(entry.signal, entry.handle);
56
87
  try {
57
88
  cleanup = materializeConfigPayload(engine, childEnvironment) ?? cleanup;
58
89
  await new Promise(resolve => setImmediate(resolve));
90
+ let result;
59
91
  if (output) {
60
- return await runPipedChild(command, args, childEnvironment, async () => { await onSpawn?.(); }, output, spawnedChild => { child = spawnedChild; });
61
- }
62
- const spawnedChild = spawn(command, args, {
63
- stdio: 'inherit',
64
- env: childEnvironment,
65
- });
66
- child = spawnedChild;
67
- const spawned = new Promise((resolve, reject) => {
68
- spawnedChild.once('spawn', resolve);
69
- spawnedChild.once('error', reject);
70
- });
71
- const closed = new Promise(resolve => {
72
- let error;
73
- spawnedChild.once('error', cause => { error = cause; });
74
- spawnedChild.once('close', status => resolve(error ? { status, error } : { status }));
75
- });
76
- await spawned;
77
- try {
78
- await onSpawn?.();
92
+ result = await runPipedChild(command, args, childEnvironment, async () => { await onSpawn?.(); }, output, spawnedChild => { child = spawnedChild; });
79
93
  }
80
- catch (error) {
81
- spawnedChild.kill('SIGTERM');
82
- await closed;
83
- throw error;
94
+ else {
95
+ // stdin is not passed on: a process outside the terminal's foreground
96
+ // group that read the terminal would be stopped by it.
97
+ // Outside the CLI's group, the optimizer would outlive a SIGKILL of the
98
+ // CLI; it watches for that and stops itself.
99
+ const spawnedChild = spawn(command, args, {
100
+ stdio: ['ignore', 'inherit', 'inherit'],
101
+ env: { ...childEnvironment, ORIZU_STOP_WITH_PARENT: '1' },
102
+ detached: true,
103
+ });
104
+ child = spawnedChild;
105
+ childGroup = spawnedChild.pid;
106
+ const spawned = new Promise((resolve, reject) => {
107
+ spawnedChild.once('spawn', resolve);
108
+ spawnedChild.once('error', reject);
109
+ });
110
+ const closed = new Promise(resolve => {
111
+ let error;
112
+ spawnedChild.once('error', cause => { error = cause; });
113
+ spawnedChild.once('close', status => resolve(error ? { status, error } : { status }));
114
+ });
115
+ // A launch failure (e.g. ENOENT) is returned, not thrown, so callers
116
+ // name the engine as they did with spawnSync.
117
+ try {
118
+ await spawned;
119
+ }
120
+ catch (error) {
121
+ return { status: null, error: error };
122
+ }
123
+ try {
124
+ await onSpawn?.();
125
+ }
126
+ catch (error) {
127
+ spawnedChild.kill('SIGTERM');
128
+ await closed;
129
+ throw error;
130
+ }
131
+ result = await closed;
132
+ // A runner that ignored the stop must not outlive the optimizer.
133
+ if (stopSignal) {
134
+ try {
135
+ process.kill(-spawnedChild.pid, 'SIGKILL');
136
+ }
137
+ catch { /* group already gone */ }
138
+ }
84
139
  }
85
- return await closed;
140
+ if (stopSignal)
141
+ return await endBySignal(stopSignal);
142
+ return result;
86
143
  }
87
144
  finally {
88
- for (const entry of handlers)
89
- process.off(entry.signal, entry.handle);
145
+ if (giveUp)
146
+ clearTimeout(giveUp);
147
+ removeHandlers();
90
148
  cleanup();
91
149
  }
92
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.43",
3
+ "version": "0.5.45",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,3 +19,14 @@ export const HOSTED_AGENT_PROMPT = `You are the Orizu agent. You work in the per
19
19
  - Keep answers short and plain. Say what you did and what the person needs to know next.
20
20
  - Follow the repository's own conventions and any instructions in its AGENTS.md.
21
21
  - An Orizu app's page link is <Orizu server>${appPagePath('<app id>')}, where <Orizu server> is the server the orizu CLI talks to (https://app.orizu.ai by default). To give someone an app's link, run \`orizu projects list\` to get the \`<team>/<project>\` slug, then \`orizu apps list --project <team>/<project>\`: it prints each app's full link. The --project flag is required here: prompts cannot run in this environment. Never guess a link.`
22
+
23
+ /**
24
+ * The prompt for one session. Every project of an approved team shares the
25
+ * team's repository (ORI-2601; ADR-041, added in PR #2292), which starts with one project's folder, so the
26
+ * agent is told which project this session is for (ORI-2601).
27
+ */
28
+ export function hostedAgentPrompt(projectId?: string | null): string {
29
+ if (!projectId) return HOSTED_AGENT_PROMPT
30
+ return `${HOSTED_AGENT_PROMPT}
31
+ - This session is for the Orizu project whose id is ${projectId}. Run \`orizu projects list --json\` to find its \`<team>/<project>\` slug, and pass that whole slug as \`--project <team>/<project>\` to orizu commands. In this repository the project's files live in the folder \`projects/<project>/\`, named by the part after the slash only (for \`acme/support-bot\`, the folder is \`projects/support-bot/\`). If that folder is missing, do not create it by hand: Orizu writes a project folder with its own files (\`orizu.project.json\`, \`README.md\`, \`memory.md\`). Tell the person this project's folder is not in the workspace yet, and keep working on what they asked. Do not change another project's folder unless the person asks.`
32
+ }
@@ -60,6 +60,7 @@ import {
60
60
  import { DEFAULT_EGRESS_CANARY_HOST, DEFAULT_HOSTED_MODEL } from './hosted-loop-lifecycle.js'
61
61
  import { composeHostedAnswerPrompt } from './hosted-question.js'
62
62
  import { resumeRunEventSink } from './hosted-run-event-sink.js'
63
+ import { seedProjectFolder } from './hosted-project-folder-seed.js'
63
64
  import { stageOrizuSkill } from './hosted-skill-staging.js'
64
65
  import { openHostedLiveChannel, type HostedLiveChannel } from './hosted-live-channel.js'
65
66
 
@@ -441,6 +442,9 @@ export interface ResolvedSession {
441
442
  agentSessionId: string | null
442
443
  seedOrigin: 'team' | 'session'
443
444
  projectId: string | null
445
+ /** The session's own project's slug and name (ORI-2609), when the server sends them. */
446
+ projectSlug: string | null
447
+ projectName: string | null
444
448
  /** Session lifetime in minutes (from client_info), if the coordinator recorded
445
449
  * it — used to derive the per-prompt max-duration cap (ALI-1061). */
446
450
  durationMinutes: number | null
@@ -547,6 +551,8 @@ export async function resolveSession(opts: {
547
551
  // A server with copying switched off omits seedOrigin; no source is bound.
548
552
  seedOrigin: session.seedOrigin === 'session' ? 'session' : 'team',
549
553
  projectId: asString(session.projectId),
554
+ projectSlug: asString(session.projectSlug),
555
+ projectName: asString(session.projectName),
550
556
  }
551
557
  }
552
558
 
@@ -1342,6 +1348,18 @@ const c = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1342
1348
  exec('git', ['-C', workspaceDir, 'config', 'user.email', AGENT_GIT_IDENTITY.email])
1343
1349
  if (!wokenDisk && !sessionCopyFilled) log(`cloned ${session.repoBranch}`)
1344
1350
 
1351
+ // 5a — ORI-2609: the team copy holds only the projects the team seed knew
1352
+ // about. Write this session's project folder if it is missing, before the
1353
+ // agent starts. Never fails the boot. A woken disk is the agent's own saved
1354
+ // state, so a folder it removed there is not put back.
1355
+ log(wokenDisk
1356
+ ? 'project folder left as the woken disk has it'
1357
+ : seedProjectFolder({
1358
+ workTree: workspaceDir,
1359
+ project: { id: session.projectId, slug: session.projectSlug, name: session.projectName },
1360
+ exec: (cmd, args) => exec(cmd, args),
1361
+ }))
1362
+
1345
1363
  // 5b — Stage the orizu skill into the cloned repo so the agent discovers the
1346
1364
  // Orizu workflows (ALI-1059). SHARED with the operator path via `stageOrizuSkill`
1347
1365
  // (one resolution chain + the harvest-safe .git/info/exclude append). Non-fatal:
@@ -82,6 +82,7 @@ export function brokerEnv(config: BrokerConfig): Record<string, string> {
82
82
  if (config.tokensFile) env.HB_TOKENS_FILE = config.tokensFile
83
83
  if (config.sessionId) env.HB_SESSION_ID = config.sessionId
84
84
  if (config.sessionBranch) env.HB_SESSION_BRANCH = config.sessionBranch
85
+ if (config.projectId) env.HB_PROJECT_ID = config.projectId
85
86
  if (config.repoTokenStatus) env.HB_REPO_TOKEN_STATUS = String(config.repoTokenStatus)
86
87
  if (config.eventsStatus) env.HB_EVENTS_STATUS = String(config.eventsStatus)
87
88
  if (config.denyWrite) env.HB_DENY_WRITE = '1'
@@ -178,6 +179,8 @@ export interface HostedBootstrapRehearsal {
178
179
  sessionId: string
179
180
  runId: string
180
181
  sessionBranch: string
182
+ /** The project the fake /sessions endpoint reports for the session. */
183
+ projectId: string
181
184
  repoFullName: string
182
185
  provider: SandboxProvider
183
186
  resolveCloneUrl: (repo: string) => string
@@ -242,6 +245,7 @@ export async function createHostedBootstrapRehearsal(
242
245
 
243
246
  const workspaceId = 'ws-hosted-1'
244
247
  const sessionId = 'sess-hosted-1'
248
+ const projectId = '6f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e'
245
249
  const runId = 'run-hosted-1'
246
250
  const sessionBranch = `orizu/session-${sessionId}`
247
251
 
@@ -280,6 +284,7 @@ export async function createHostedBootstrapRehearsal(
280
284
  fullRunApi: options.fullRunApi,
281
285
  sessionId,
282
286
  sessionBranch,
287
+ projectId,
283
288
  })
284
289
 
285
290
  // Real authenticated smart-HTTP git server on loopback: this is what makes the
@@ -301,6 +306,7 @@ export async function createHostedBootstrapRehearsal(
301
306
  sessionId,
302
307
  runId,
303
308
  sessionBranch,
309
+ projectId,
304
310
  repoFullName: repo.repoFullName,
305
311
  provider,
306
312
  resolveCloneUrl: () => cloneUrl,
@@ -70,6 +70,8 @@ export interface BrokerConfig {
70
70
  * a pre-seeded session branch on the local bare repo). */
71
71
  sessionId?: string
72
72
  sessionBranch?: string
73
+ /** E2E: the project the /sessions endpoint reports, as the real route does. */
74
+ projectId?: string
73
75
  }
74
76
 
75
77
  export function readBrokerConfigFromEnv(env: NodeJS.ProcessEnv): BrokerConfig {
@@ -87,6 +89,7 @@ export function readBrokerConfigFromEnv(env: NodeJS.ProcessEnv): BrokerConfig {
87
89
  fullRunApi: env.HB_FULL_RUN_API === '1',
88
90
  sessionId: env.HB_SESSION_ID,
89
91
  sessionBranch: env.HB_SESSION_BRANCH,
92
+ projectId: env.HB_PROJECT_ID,
90
93
  }
91
94
  }
92
95
 
@@ -151,7 +154,8 @@ export function startBrokerServer(config: BrokerConfig): { port: number; stop: (
151
154
  const id = config.sessionId ?? `sess-e2e-${sessions}`
152
155
  const repoBranch = config.sessionBranch ?? `orizu/session-${id}`
153
156
  return json(
154
- { session: { id, workspaceId: sessionsMatch[1], repoBranch, status: 'active' } },
157
+ // Like the real route, a session has a project only when the request names one.
158
+ { session: { id, workspaceId: sessionsMatch[1], projectId: typeof body.projectSlug === 'string' ? config.projectId ?? null : null, repoBranch, status: 'active' } },
155
159
  201
156
160
  )
157
161
  }
@@ -53,7 +53,7 @@ import { readFileSync } from 'fs'
53
53
 
54
54
  import { refreshHostedModels } from '../src/hosted-model-cache.mjs'
55
55
 
56
- import { HOSTED_AGENT_NAME, HOSTED_AGENT_PROMPT } from './hosted-agent-prompt.js'
56
+ import { HOSTED_AGENT_NAME, hostedAgentPrompt } from './hosted-agent-prompt.js'
57
57
  import { parseModelIdentity } from './provider-registry.js'
58
58
 
59
59
  import { HARNESS_NAMES } from './hosted-harness.js'
@@ -1098,6 +1098,8 @@ export interface OpenCodeConfigOptions {
1098
1098
  /** OpenCode permission map; defaults to allow-all EXCEPT the headless-denied
1099
1099
  * tools below (sandbox is the boundary for everything else). */
1100
1100
  permission?: Record<string, unknown>
1101
+ /** The session's Orizu project, named in the agent's prompt (ORI-2601). */
1102
+ projectId?: string | null
1101
1103
  }
1102
1104
 
1103
1105
  /**
@@ -1134,7 +1136,7 @@ export function buildOpenCodeConfigContent(opts: OpenCodeConfigOptions): string
1134
1136
  model: opts.model,
1135
1137
  permission: opts.permission ?? defaultHeadlessPermission(),
1136
1138
  // Replaces OpenCode's built-in "You are opencode…" prompt (ORI-2207).
1137
- agent: { [HOSTED_AGENT_NAME]: { mode: 'primary', prompt: HOSTED_AGENT_PROMPT } },
1139
+ agent: { [HOSTED_AGENT_NAME]: { mode: 'primary', prompt: hostedAgentPrompt(opts.projectId) } },
1138
1140
  })
1139
1141
  }
1140
1142
 
@@ -55,6 +55,8 @@ export interface HostedLoopContext {
55
55
  taskFile: string
56
56
  /** Directory the agent operates in (the cloned session-branch workspace). */
57
57
  workspaceDir: string
58
+ /** The session's Orizu project, named in the agent's prompt (ORI-2601). */
59
+ projectId?: string | null
58
60
  /** Validated destination branch for the CLI-owned checkpoint push. */
59
61
  sessionBranch: string
60
62
  /** Credential-free repository locator established by bootstrap. */
@@ -674,6 +674,7 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
674
674
  spawned = await spawnImpl({
675
675
  model: context.model,
676
676
  permission: permissionForOrigin(context.sessionOrigin),
677
+ projectId: context.projectId,
677
678
  cwd: context.workspaceDir,
678
679
  port: context.opencodePort,
679
680
  env: buildOpenCodeSpawnEnv(context),
@@ -1063,6 +1064,7 @@ function validateHostedLoopContext(value: unknown): { context: HostedLoopContext
1063
1064
  if (record.sandboxBudgetMs !== undefined && (typeof record.sandboxBudgetMs !== 'number' || !Number.isFinite(record.sandboxBudgetMs) || record.sandboxBudgetMs <= 0)) fields.push('sandboxBudgetMs')
1064
1065
  if (record.opencodePort !== undefined && (typeof record.opencodePort !== 'number' || !Number.isInteger(record.opencodePort) || record.opencodePort <= 0 || record.opencodePort > 65_535)) fields.push('opencodePort')
1065
1066
  for (const name of OPTIONAL_CONTEXT_STRINGS) if (record[name] !== undefined && typeof record[name] !== 'string') fields.push(name)
1067
+ if (record.projectId !== undefined && record.projectId !== null && !isNonemptyString(record.projectId)) fields.push('projectId')
1066
1068
  for (const name of ['prebaked', 'runSetupHook'] as const) if (record[name] !== undefined && typeof record[name] !== 'boolean') fields.push(name)
1067
1069
  return { context: fields.length === 0 ? value as HostedLoopContext : null, fields: [...new Set(fields)] }
1068
1070
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ORI-2609: every project of an approved team shares the team's repository,
3
+ * but the team seed wrote only the first project's folder. A hosted session
4
+ * for another project writes that project's folder into its own checkout at
5
+ * boot, exactly as the team seed would have, and commits it locally. The
6
+ * session's normal save carries the commit; nothing is pushed here.
7
+ *
8
+ * A folder that is already there, whole or partial, is never touched, so a
9
+ * second session never duplicates or overwrites one. Never throws: a seed
10
+ * failure is reported for the boot to log, and the session still starts.
11
+ */
12
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ import {
16
+ buildSeedContractFiles,
17
+ SEED_COMMIT_AUTHOR_EMAIL,
18
+ SEED_COMMIT_AUTHOR_NAME,
19
+ SEED_COMMIT_DATE,
20
+ } from './workspace-bootstrap-seed.js'
21
+
22
+ export interface ProjectFolderSeedProject {
23
+ id: string | null
24
+ slug: string | null
25
+ name: string | null
26
+ }
27
+
28
+ type SeedExec = (cmd: string, args: string[]) => { status: number; stdout: string; stderr: string }
29
+
30
+ // One plain folder name: no separators, no traversal, no leading dot or dash.
31
+ const SAFE_PROJECT_SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
32
+
33
+ function readTeamFacts(workTree: string): { id: string; slug: string } {
34
+ // The team manifest the team seed wrote is the source of the team facts, so
35
+ // the new project's manifest agrees with the repository it lands in.
36
+ const manifest = JSON.parse(readFileSync(join(workTree, 'orizu.team.json'), 'utf8')) as Record<string, unknown>
37
+ if (typeof manifest.slug !== 'string' || manifest.slug.length === 0) {
38
+ throw new Error('orizu.team.json carries no team slug')
39
+ }
40
+ // The team id does not appear in project files; it is passed for completeness.
41
+ return { id: typeof manifest.teamId === 'string' ? manifest.teamId : '', slug: manifest.slug }
42
+ }
43
+
44
+ /** Returns the one line the boot logs. */
45
+ export function seedProjectFolder(input: {
46
+ workTree: string
47
+ project: ProjectFolderSeedProject
48
+ exec: SeedExec
49
+ }): string {
50
+ const { workTree, project, exec } = input
51
+ if (!project.id) return 'project folder skipped: the session names no project'
52
+ // Without the slug there is no folder name; the server leaves it out when it
53
+ // could not read the project.
54
+ if (!project.slug) return 'project folder skipped: the server sent no project slug'
55
+ try {
56
+ const slug = project.slug
57
+ if (!SAFE_PROJECT_SLUG.test(slug)) throw new Error('the project slug is not a plain folder name')
58
+ const folder = `projects/${slug}`
59
+ const projectsDir = join(workTree, 'projects')
60
+ if (existsSync(projectsDir) && !lstatSync(projectsDir).isDirectory()) {
61
+ throw new Error('projects/ is not a folder')
62
+ }
63
+ if (existsSync(join(workTree, folder))) return `project folder ${folder}/ already present`
64
+
65
+ // Byte-for-byte the files the team seed writes for this project.
66
+ const files = buildSeedContractFiles({
67
+ team: readTeamFacts(workTree),
68
+ projects: [{ id: project.id, slug, name: project.name }],
69
+ }).filter(file => file.path.startsWith(`${folder}/`))
70
+ for (const file of files) {
71
+ const target = join(workTree, file.path)
72
+ mkdirSync(dirname(target), { recursive: true })
73
+ writeFileSync(target, file.bytes, { mode: 0o644 })
74
+ }
75
+ // No hook the checkout's config names runs inside the boot (as in harvest).
76
+ // The exec seam takes no `env` option, so `env` pins the commit dates.
77
+ const git = (step: string, ...args: string[]) => {
78
+ const result = exec('env', [
79
+ `GIT_AUTHOR_DATE=${SEED_COMMIT_DATE}`, `GIT_COMMITTER_DATE=${SEED_COMMIT_DATE}`,
80
+ 'git', '-C', workTree, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args,
81
+ ])
82
+ if (result.status !== 0) throw new Error(`git ${step} failed: ${(result.stderr || result.stdout).trim()}`)
83
+ }
84
+ git('add', 'add', '--', folder)
85
+ // Paths after `--` commit only this folder, whatever else is staged. A fixed
86
+ // author, committer and date make every session that starts from the same
87
+ // team head produce the same commit, so two sessions' publishes share it
88
+ // rather than conflict on files neither of them touched.
89
+ git('commit', '-c', 'commit.gpgsign=false', '-c', `user.name=${SEED_COMMIT_AUTHOR_NAME}`, '-c', `user.email=${SEED_COMMIT_AUTHOR_EMAIL}`,
90
+ 'commit', '--quiet', '--no-verify', '-m', `Add the ${slug} project folder`, '--', folder)
91
+ return `project folder ${folder}/ seeded`
92
+ } catch (error) {
93
+ return `project folder seed failed: ${error instanceof Error ? error.message : String(error)}`
94
+ }
95
+ }
@@ -678,8 +678,9 @@ export async function startHostedSession(
678
678
  }),
679
679
  }
680
680
  )
681
- const session = sessionResp.session as { id?: unknown; repoBranch?: unknown } | undefined
681
+ const session = sessionResp.session as { id?: unknown; repoBranch?: unknown; projectId?: unknown } | undefined
682
682
  const sessionId = requireString(session?.id, 'session id')
683
+ const sessionProjectId = typeof session?.projectId === 'string' && session.projectId ? session.projectId : undefined
683
684
  const sessionBranch = requireString(session?.repoBranch, 'session repoBranch')
684
685
  log(`session ${sessionId} on ${sessionBranch}`)
685
686
 
@@ -821,6 +822,7 @@ export async function startHostedSession(
821
822
  taskFile: `${paths.runDirAbs}/task.txt`,
822
823
  workspaceDir: paths.repoDirRel,
823
824
  sessionBranch,
825
+ projectId: sessionProjectId,
824
826
  repositoryRemote: repo.cloneUrl,
825
827
  repositoryCredentialHelper: bootstrap.credentialHelper,
826
828
  model,