humanish 0.121.0 → 0.123.0

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 (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/actors/computer-use/actor.d.ts +3 -0
  5. package/dist/actors/computer-use/actor.js +1 -0
  6. package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
  7. package/dist/actors/computer-use/loop/backstop.js +13 -1
  8. package/dist/actors/computer-use/loop/clock.d.ts +20 -0
  9. package/dist/actors/computer-use/loop/clock.js +64 -0
  10. package/dist/actors/computer-use/loop/ending.d.ts +1 -1
  11. package/dist/actors/computer-use/loop/session.d.ts +1 -1
  12. package/dist/actors/computer-use/loop/session.js +2 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +43 -8
  16. package/dist/actors/computer-use/wait.d.ts +3 -2
  17. package/dist/actors/computer-use/wait.js +5 -4
  18. package/dist/actors/contract.d.ts +3 -3
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/stop-cause.js +1 -0
  21. package/dist/analysis/automatic.js +2 -1
  22. package/dist/analysis/evidence.js +41 -21
  23. package/dist/cli/commands/study-run.js +9 -3
  24. package/dist/feedback/derivative-text.d.ts +12 -0
  25. package/dist/feedback/derivative-text.js +130 -0
  26. package/dist/feedback/export-bundle.js +38 -131
  27. package/dist/feedback/export.js +9 -1
  28. package/dist/observer/run-routes.js +61 -18
  29. package/dist/routes/computer-use/bundle.d.ts +0 -1
  30. package/dist/routes/computer-use/bundle.js +0 -1
  31. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  32. package/dist/routes/computer-use/diagnostics.js +4 -0
  33. package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
  34. package/dist/routes/computer-use/fanout-bundle.js +2 -12
  35. package/dist/routes/computer-use/live-phase.d.ts +0 -2
  36. package/dist/routes/computer-use/live-phase.js +2 -4
  37. package/dist/routes/computer-use/participant-execution.d.ts +9 -15
  38. package/dist/routes/computer-use/participant-execution.js +22 -31
  39. package/dist/routes/computer-use/participant-model.js +17 -0
  40. package/dist/routes/computer-use/participant-runs.d.ts +11 -1
  41. package/dist/routes/computer-use/participant-runs.js +31 -4
  42. package/dist/routes/computer-use/plan.js +8 -1
  43. package/dist/routes/computer-use/result.js +1 -2
  44. package/dist/routes/computer-use/types.d.ts +17 -5
  45. package/dist/routes/shared-world/provisioned.js +2 -2
  46. package/dist/run/bundle-shape.js +2 -2
  47. package/dist/run/bundle.d.ts +5 -2
  48. package/dist/run/bundle.js +4 -1
  49. package/dist/run/detail.d.ts +6 -0
  50. package/dist/run/detail.js +14 -1
  51. package/dist/run/host-suspension.d.ts +2 -2
  52. package/dist/run/host-suspension.js +2 -2
  53. package/dist/run/judge.d.ts +1 -1
  54. package/dist/run/locate.js +3 -1
  55. package/dist/run/projection.d.ts +7 -0
  56. package/dist/run/projection.js +10 -0
  57. package/dist/run/run.js +7 -1
  58. package/dist/run/stored-runs.js +10 -4
  59. package/dist/run/stream-shape.d.ts +11 -1
  60. package/dist/run/stream-shape.js +26 -2
  61. package/dist/run/stream-trace.d.ts +68 -0
  62. package/dist/run/stream-trace.js +260 -0
  63. package/dist/run/streams.d.ts +20 -0
  64. package/dist/study/arrivals.d.ts +52 -5
  65. package/dist/study/arrivals.js +105 -15
  66. package/dist/study/bundle-estimate.d.ts +48 -0
  67. package/dist/study/bundle-estimate.js +159 -0
  68. package/dist/study/doctor.js +4 -0
  69. package/dist/study/keys.d.ts +6 -0
  70. package/dist/study/keys.js +4 -0
  71. package/dist/study/migrate/v2.js +8 -5
  72. package/dist/study/parse/actors.d.ts +1 -1
  73. package/dist/study/parse/actors.js +33 -4
  74. package/dist/study/plan-participants.d.ts +10 -1
  75. package/dist/study/plan-participants.js +10 -1
  76. package/dist/study/plan-types.d.ts +3 -0
  77. package/dist/study/plan.js +22 -2
  78. package/dist/study/preflight.js +19 -1
  79. package/dist/study/types.d.ts +26 -0
  80. package/dist/study/validation.d.ts +3 -2
  81. package/dist/study/validation.js +22 -4
  82. package/dist/tui-app.js +87 -87
  83. package/dist/verify/artifacts.d.ts +17 -10
  84. package/dist/verify/artifacts.js +22 -13
  85. package/dist/verify/scan-limit.d.ts +6 -0
  86. package/dist/verify/scan-limit.js +25 -0
  87. package/dist/verify/verify.d.ts +1 -1
  88. package/dist/verify/verify.js +68 -18
  89. package/docs/architecture/github-feedback-loop.md +1 -1
  90. package/docs/contracts/README.md +1 -1
  91. package/docs/contracts/run-bundle.md +89 -8
  92. package/docs/contracts/schemas.md +38 -11
  93. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -8,6 +8,37 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.123.0: Run bundle v2 writes each trace once, bundle size estimate (2026-10-12)
12
+
13
+ humanish 0.123.0 writes each participant's trace once, outside run.json, so long studies stay
14
+ within the bundle's size limits, and estimates a study's bundle before it runs. Run bundles are now
15
+ `humanish.run-bundle.v2`: a participant's trace items live only in its trace file
16
+ (`actor.json`, `actors/<stream>.json` or `actor-<surface>.json`), and run.json indexes each one by
17
+ path, size and sha256. On a synthetic 8-participant, 1,000-turn run, run.json fell from 17 MB to
18
+ 74 KB. Every reader loads traces through one loader that checks the index and refuses a missing,
19
+ altered or shared trace; a refused trace shows as evidence unavailable, and an unfinished run
20
+ grades `local_only`. Bundles from earlier versions still load unchanged; npm 0.122.0 and earlier
21
+ refuse a v2 bundle by name. `study check` and `run` estimate the bundle's size and file count and
22
+ warn before any sandbox is created when it may pass the analysis or verify limit, and the
23
+ public-safety scan's file limit grows with the number of participants.
24
+
25
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.123.0)
26
+
27
+ ## 0.122.0: Participants end on the clock, a harness error ends only its participant (2026-10-12)
28
+
29
+ humanish 0.122.0 lets a participant end at a scheduled time and keeps one participant's harness
30
+ error from stopping the rest of a study. `participants[].endAfterMs`, on the same clock as
31
+ `startAfterMs`, ends a participant's session at that time with stop cause `scheduled_end`,
32
+ counted as reaching the goal; from `endAfterMs - windDownMs` (10 minutes by default) each turn
33
+ tells the participant the end time and the minutes left, its turns that only wait no longer count
34
+ toward the idle limits before then, and its sandbox is sized from its own window. `actor.clock {
35
+ timeZone }` adds the local time to each turn, and `participants[].idleWaitMs` sets one
36
+ participant's wait length, so staff on a long shift can check the screen less often than patients.
37
+ A participant's harness error now ends only that participant: the study-wide fail-fast is removed,
38
+ and every later participant starts on its schedule.
39
+
40
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.122.0)
41
+
11
42
  ## 0.121.0: One failed desktop fails alone, host sleep named as the cause (2026-10-10)
12
43
 
13
44
  humanish 0.121.0 keeps one participant's failed desktop from costing a whole hosted study, and
package/CONTEXT.md CHANGED
@@ -12,7 +12,7 @@ bundle and output fields keep older spellings; the second table maps them.
12
12
  | Term | Meaning | Where it lives in the code |
13
13
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
14
  | study | What a user designs and runs: one YAML file that declares the subject, the actor, the participants and the caps. The file lives at `humanish/studies/<id>.yaml` with schema `humanish.study.v3`, read by `humanish study` and `--study`. humanish refuses a 0.107 `humanish.lab.v2` file, and `humanish migrate` converts it (second table). | `src/study/`; `StudyConfig` in `src/study/types.ts`, parsed by `parseStudy` in `src/study/config.ts` |
15
- | run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.v1`) as the source of truth. | `src/run/`; `RunBundle` in `src/run/bundle.ts` |
15
+ | run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.v2`) as the source of truth and each participant's trace in its own file. | `src/run/`; `RunBundle` in `src/run/bundle.ts` |
16
16
  | dry run | A run with no live participant, from `--dry-run` or `mode: dry-run`. humanish writes a synthetic run bundle, review and Observer with no browser, model, keys or provider spend, and tests no product behavior. The `preview` route only dry-runs; "preview" names that route and nothing else. | `runDryRun` in `src/run/dry-run.ts`; `resolveStudyDryRun` in `src/study/plan.ts` |
17
17
  | participant | One simulated person, or agent, taking part in a study. The planner builds each one's record (id, persona id, assignment, labels and the route's own fields). Shared-world participants resolve the plan's records; computer-use participants rebuild the same records from the study config with `computerUseParticipants` (`src/study/plan-participants.ts`) and resolve those. `ResolvedParticipant` adds the bundle ids, persona and prompt. `run.json` records each one as a `simulations[]` entry with one or more `streams[]`. | `ResolvedParticipant` in `src/run/participant.ts`; the planned records in `src/study/plan-participants.ts`. Computer-use and shared-world participants run a `DesktopParticipantRun` (`src/routes/computer-use/types.ts`). The terminal session (`src/routes/terminal/session.ts`), scripted surfaces (`src/routes/scripted/plan.ts`) and fixture simulations (`src/run/dry-run.ts`) still build their own. |
18
18
  | persona | Who a participant is: traits, background, device. `humanish/personas/<id>.yaml`. | `src/study/persona.ts`, `src/study/persona-resolve.ts` |
package/README.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # humanish
2
2
 
3
- Synthetic user research for apps, CLIs, and agent-facing product flows. AI participants with a persona and a task use your app on real desktops, and each run leaves evidence you can verify: what they did, where they got stuck, and what it cost.
3
+ [![npm version](https://img.shields.io/npm/v/humanish)](https://www.npmjs.com/package/humanish) [![npm downloads per month](https://img.shields.io/npm/dm/humanish)](https://www.npmjs.com/package/humanish) [![CI status on main](https://img.shields.io/github/actions/workflow/status/danielgwilson/humanish/ci.yml?branch=main&label=CI)](https://github.com/danielgwilson/humanish/actions/workflows/ci.yml?query=branch%3Amain) [![License: MIT](https://img.shields.io/npm/l/humanish)](LICENSE) [![Node.js version](https://img.shields.io/node/v/humanish)](https://nodejs.org/)
4
4
 
5
- [![The Observer grid of a saved eight-participant study: eight desktops in one multiplayer lobby, each tile a participant's live screen](https://humanish.dev/runs/lobby-0927/poster.jpg)](https://humanish.dev/demo)
5
+ Synthetic user research for apps, CLIs, and agent-facing product flows. AI participants with a persona and a task use your app on real desktops, and each run leaves evidence you can verify: what they did, where they got stuck, and what it cost.
6
6
 
7
- **[Watch a saved run](https://humanish.dev/demo).** Eight synthetic participants joined one
8
- lobby of a multiplayer game on its live deployment, each on its own hosted desktop. The page
9
- replays the real Observer; nothing runs from it.
7
+ <a href="https://humanish.dev/demo">
8
+ <picture>
9
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-grid-dark.webp">
10
+ <img alt="The humanish Observer replaying a saved study five minutes into its 13-minute recording: a grid of eight participants' desktops in a multiplayer movie-guessing game, seven in the same round and one held at the join page, under tabs for 8 participants and 7 findings" src="https://humanish.dev/runs/lobby-0927/observer-grid-light.webp">
11
+ </picture>
12
+ </a>
13
+
14
+ **[Watch the replay](https://humanish.dev/demo).** The image is the Observer partway through a
15
+ saved study. Eight synthetic participants joined one lobby of a multiplayer movie-guessing game
16
+ on its live deployment, each on its own hosted desktop, and each tile shows one participant's
17
+ screen at the same moment of the recording. Six reached the final standings. Two were blocked,
18
+ among them Player six, who arrived after the game started and is still on the join page. The
19
+ Findings tab holds the seven findings the analysis wrote. The demo page replays this run in the
20
+ Observer, and nothing runs from it.
10
21
 
11
22
  ## Learn how a study works
12
23
 
@@ -19,6 +30,14 @@ disposable browser VM on your machine. On the computer-use route a model drives
19
30
  participant: every turn it reads a screenshot and answers with mouse and keyboard actions, until
20
31
  the participant finishes, gets stuck or reaches a limit.
21
32
 
33
+ <picture>
34
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-player-dark.webp">
35
+ <img alt="The Observer with one participant open: its desktop during a round of a movie-guessing game, replay controls and a filmstrip of captures below, and on the right its trace, with the participant's reasoning and each TAB and ENTER keypress" src="https://humanish.dev/runs/lobby-0927/observer-player-light.webp">
36
+ </picture>
37
+
38
+ Player four from the run above, a keyboard-only participant: its screen, and beside it each
39
+ keypress and the reasoning it wrote before acting.
40
+
22
41
  The run writes its evidence to gitignored `.humanish/runs/<run id>/`: screenshots, every
23
42
  action, each participant's report and the estimated cost. The Observer replays it,
24
43
  `humanish review` prints the findings of the analysis that follows a live run, and
@@ -26,6 +45,14 @@ action, each participant's report and the estimated cost. The Observer replays i
26
45
  [How a study works](https://humanish.dev/docs/concepts) walks through one annotated study file,
27
46
  and the [study file reference](https://humanish.dev/docs/study-files) lists every field.
28
47
 
48
+ <picture>
49
+ <source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-findings-dark.webp">
50
+ <img alt="The Observer's Findings tab: the first of seven findings, that three participants found keyboard guessing hard to track and one stopped mid-game, with the analysis's summary, what to check next, a cited capture and the timeline of evidence moments behind it" src="https://humanish.dev/runs/lobby-0927/observer-findings-light.webp">
51
+ </picture>
52
+
53
+ The first of the seven findings the analysis wrote for the same run, with the evidence moments
54
+ it cites.
55
+
29
56
  ## Try it without keys
30
57
 
31
58
  In a project directory, with Node.js 22.19 or newer:
@@ -308,7 +335,7 @@ and the library exports.
308
335
  - Breaking changes ship together in one minor release. Its release notes carry a migration
309
336
  table: each old form, its new form, and the change to make.
310
337
  - The study file and the run bundle carry versioned schemas, `humanish.study.v3` and
311
- `humanish.run-bundle.v1`. A breaking change to either one bumps its version.
338
+ `humanish.run-bundle.v2`. A breaking change to either one bumps its version.
312
339
 
313
340
  Pin the exact version in `package.json` and read [CHANGELOG.md](CHANGELOG.md) before upgrading.
314
341
 
@@ -1,5 +1,6 @@
1
1
  import type { ActorPersonaRef, ActorTokenUsage, ActorTraceItem } from "../contract.js";
2
2
  import { type CuaExecutor, type CuaLiveMetadata, type CuaLoopResult, type CuaProvider, type CuaSafetyCheck } from "./loop.js";
3
+ import type { ParticipantClock } from "./loop/types.js";
3
4
  import { type E2BDesktopExecutorOptions, type E2BDesktopLike } from "../../substrates/e2b/desktop-executor.js";
4
5
  import { type OpenAiResponsesProviderOptions } from "./openai-provider.js";
5
6
  import { type RedactionHooks } from "../../evidence/redaction.js";
@@ -42,6 +43,8 @@ export interface CuaActorSessionOptions {
42
43
  maxWaitMs?: number;
43
44
  /** How long a wait with no duration lasts in an idle turn. See LoopRunOptions.idleWaitMs. */
44
45
  idleWaitMs?: number;
46
+ /** The participant's time zone and scheduled end, forwarded to the loop. See CuaLoopOptions.clock. */
47
+ clock?: ParticipantClock;
45
48
  /**
46
49
  * Redact persisted screenshots (blur+downscale). Default `false`: full fidelity for local use.
47
50
  * Set true for unowned subjects or share-as-is bundles. The provider always sees raw frames.
@@ -43,6 +43,7 @@ export async function runCuaActorSession(options) {
43
43
  ...(options.writeScreenshot === undefined ? {} : { writeScreenshot: options.writeScreenshot }),
44
44
  ...(options.stopWhen === undefined ? {} : { stopWhen: options.stopWhen }),
45
45
  ...(options.dwell === undefined ? {} : { dwell: options.dwell }),
46
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
46
47
  ...(options.tasks === undefined ? {} : { tasks: options.tasks }),
47
48
  ...(options.maxUsd === undefined ? {} : { maxUsd: options.maxUsd }),
48
49
  ...(options.estimateTurnCostUsd === undefined
@@ -34,9 +34,14 @@ export interface BackstopStep {
34
34
  /** The gave_up reason when a streak tripped. */
35
35
  readonly tripReason: string | undefined;
36
36
  }
37
- /** Fold one acted turn and the observation after it into the backstop. */
37
+ /**
38
+ * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
39
+ * participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
40
+ * toward neither streak and earns no nudge. A turn that acts is folded as usual.
41
+ */
38
42
  export declare function advanceBackstop(previous: Backstop, turn: {
39
43
  actions: readonly CuaAction[];
40
44
  observation: CuaObservation;
41
45
  heardNewSpeech: boolean;
46
+ waitingIsTheTask?: boolean;
42
47
  }, limits: BackstopLimits): BackstopStep;
@@ -122,9 +122,21 @@ export function startBackstop(observation) {
122
122
  idleProgressForgivenessUsed: 0,
123
123
  };
124
124
  }
125
- /** Fold one acted turn and the observation after it into the backstop. */
125
+ /**
126
+ * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
127
+ * participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
128
+ * toward neither streak and earns no nudge. A turn that acts is folded as usual.
129
+ */
126
130
  export function advanceBackstop(previous, turn, limits) {
127
131
  const idle = isIdleTurn(turn.actions);
132
+ if (idle && turn.waitingIsTheTask === true)
133
+ return {
134
+ backstop: { ...previous, progressKey: progressKeyOf(turn.observation), consecutiveIdle: 0 },
135
+ idle,
136
+ progressed: true,
137
+ hints: [],
138
+ tripReason: undefined,
139
+ };
128
140
  const progressKey = progressKeyOf(turn.observation);
129
141
  const frameChanged = progressKey !== previous.progressKey;
130
142
  // Corroboration. A stale frame alone is not evidence of a stuck agent. The frame hash is
@@ -0,0 +1,20 @@
1
+ import type { Stop } from "./ending.js";
2
+ import type { LoopSession } from "./session.js";
3
+ import type { CuaObservation, ParticipantClock } from "./types.js";
4
+ /**
5
+ * The time text for the request the loop is about to send: the local time when the participant
6
+ * has a time zone, and the end time and the minutes left from the start of its wind-down, or
7
+ * after it tried to end its session before its end (`askedToEnd`). Undefined when there is
8
+ * nothing to say.
9
+ */
10
+ export declare function clockHint(clock: ParticipantClock | undefined, now: () => number, askedToEnd?: boolean): string | undefined;
11
+ /**
12
+ * Whether the participant has a scheduled end still to come. Its own end before then does not end
13
+ * the session: the end is what the study declared for it.
14
+ */
15
+ export declare function beforeScheduledEnd(session: LoopSession): boolean;
16
+ /**
17
+ * The stop at the participant's scheduled end, or undefined before it. The session passes as a
18
+ * planned end and the participant is asked for its closing report on the last observation.
19
+ */
20
+ export declare function scheduledEndStop(session: LoopSession, observation: CuaObservation): Stop | undefined;
@@ -0,0 +1,64 @@
1
+ import { plural } from "../../../run/text.js";
2
+ // What the participant knows about the time. A hosted desktop shows a maximized browser with no
3
+ // OS clock, so a participant told "your shift ends at 11:00" cannot relate it to now unless each
4
+ // turn says so. The text rides the request's context hint, which every provider sends with the
5
+ // turn (OpenAI as a user message, Codex and the local agents as the harness note).
6
+ /**
7
+ * The time text for the request the loop is about to send: the local time when the participant
8
+ * has a time zone, and the end time and the minutes left from the start of its wind-down, or
9
+ * after it tried to end its session before its end (`askedToEnd`). Undefined when there is
10
+ * nothing to say.
11
+ */
12
+ export function clockHint(clock, now, askedToEnd = false) {
13
+ if (clock === undefined)
14
+ return undefined;
15
+ const nowMs = now();
16
+ const parts = [];
17
+ if (clock.timeZone !== undefined)
18
+ parts.push(`The local time is ${hourMinute(nowMs, clock.timeZone)} (${clock.timeZone}).`);
19
+ const { end } = clock;
20
+ if (end !== undefined && (askedToEnd || nowMs >= end.atMs - end.windDownMs)) {
21
+ const minutesLeft = Math.max(1, Math.ceil((end.atMs - nowMs) / 60_000));
22
+ parts.push(`${askedToEnd ? "Your session is not over. " : ""}Your session ends at ${endTime(clock, end.atMs)}, in ${plural(minutesLeft, "minute")}. Keep working until then. The session ends by itself at that time, and you will then be asked for your closing account.`);
23
+ }
24
+ return parts.length === 0 ? undefined : parts.join(" ");
25
+ }
26
+ /**
27
+ * Whether the participant has a scheduled end still to come. Its own end before then does not end
28
+ * the session: the end is what the study declared for it.
29
+ */
30
+ export function beforeScheduledEnd(session) {
31
+ const end = session.settings.clock?.end;
32
+ return end !== undefined && session.now() < end.atMs;
33
+ }
34
+ /**
35
+ * The stop at the participant's scheduled end, or undefined before it. The session passes as a
36
+ * planned end and the participant is asked for its closing report on the last observation.
37
+ */
38
+ export function scheduledEndStop(session, observation) {
39
+ const end = session.settings.clock?.end;
40
+ if (end === undefined || session.now() < end.atMs)
41
+ return undefined;
42
+ return {
43
+ completionReason: "goal_satisfied",
44
+ reason: `the participant's scheduled end at ${endTime(session.settings.clock, end.atMs)} was reached`,
45
+ stopCause: "scheduled_end",
46
+ debriefTrigger: { kind: "scheduled_end", observation },
47
+ };
48
+ }
49
+ // The end to the nearest minute: a run starts at no particular second, and "ends at 00:09" read
50
+ // at 00:09:06 looks past.
51
+ function endTime(clock, atMs) {
52
+ const minute = Math.round(atMs / 60_000) * 60_000;
53
+ return clock.timeZone === undefined
54
+ ? `${hourMinute(minute, "UTC")} UTC`
55
+ : hourMinute(minute, clock.timeZone);
56
+ }
57
+ function hourMinute(ms, timeZone) {
58
+ return new Intl.DateTimeFormat("en-GB", {
59
+ timeZone,
60
+ hour: "2-digit",
61
+ minute: "2-digit",
62
+ hourCycle: "h23",
63
+ }).format(ms);
64
+ }
@@ -8,7 +8,7 @@ import type { CuaObservation, CuaProvider, CuaTurn } from "./types.js";
8
8
  * the participant's own ending when only its impressions are missing.
9
9
  */
10
10
  export interface DebriefTrigger {
11
- readonly kind: "stop_when" | "dwell" | "participant_end";
11
+ readonly kind: "stop_when" | "dwell" | "scheduled_end" | "participant_end";
12
12
  /** The final observation, already captured; the debrief request sends it without observing. */
13
13
  readonly observation: CuaObservation;
14
14
  }
@@ -12,7 +12,7 @@ export type ScreenshotRef = NonNullable<ActorTraceItem["screenshotRef"]>;
12
12
  * wrapped callbacks are fields on LoopSession instead. Callers destructure hooks to call them.
13
13
  */
14
14
  export type LoopSettings = {
15
- readonly [K in "instructions" | "persona" | "redaction" | "stopWhen" | "dwell" | "tasks" | "maxUsd" | "overRunBudget" | "estimateTurnCostUsd" | "onObservedUrl" | "onMessage" | "onScreenshot" | "onTrace"]: LoopRunOptions[K];
15
+ readonly [K in "instructions" | "persona" | "redaction" | "stopWhen" | "dwell" | "clock" | "tasks" | "maxUsd" | "overRunBudget" | "estimateTurnCostUsd" | "onObservedUrl" | "onMessage" | "onScreenshot" | "onTrace"]: LoopRunOptions[K];
16
16
  };
17
17
  /** The participant's dispatched actions, as the backstop and failure notices describe them. */
18
18
  export interface ActionHistory {
@@ -70,13 +70,14 @@ export class LoopSession {
70
70
  scrubText;
71
71
  constructor(options) {
72
72
  // Read once, in this order: a caller mutating its options object mid-run changes nothing.
73
- const { instructions, provider, executor, persona, redaction, timeoutMs, now, signal, idleSteps = DEFAULT_IDLE_STEPS, noProgressSteps = DEFAULT_NO_PROGRESS_STEPS, maxWaitMs, idleWaitMs, acknowledgeSafetyChecks = () => null, redactScreenshots = false, scrubText = (text) => text, writeScreenshot = async (name) => `screenshots/${name}`, stopWhen, dwell, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tasks, maxUsd, overRunBudget, estimateTurnCostUsd, requireReportedUsageForSpendCap = false, onObservedUrl, onMessage, onScreenshot, onTrace, } = options;
73
+ const { instructions, provider, executor, persona, redaction, timeoutMs, now, signal, idleSteps = DEFAULT_IDLE_STEPS, noProgressSteps = DEFAULT_NO_PROGRESS_STEPS, maxWaitMs, idleWaitMs, acknowledgeSafetyChecks = () => null, redactScreenshots = false, scrubText = (text) => text, writeScreenshot = async (name) => `screenshots/${name}`, stopWhen, dwell, clock, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tasks, maxUsd, overRunBudget, estimateTurnCostUsd, requireReportedUsageForSpendCap = false, onObservedUrl, onMessage, onScreenshot, onTrace, } = options;
74
74
  this.settings = {
75
75
  instructions,
76
76
  persona,
77
77
  redaction,
78
78
  stopWhen,
79
79
  dwell,
80
+ clock,
80
81
  tasks,
81
82
  maxUsd,
82
83
  overRunBudget,
@@ -4,6 +4,14 @@ import type { RedactionHooks } from "../../../evidence/redaction.js";
4
4
  import type { DwellWindow, StopWhen } from "../../stop-conditions.js";
5
5
  import type { StudyTask } from "../../../study/tasks.js";
6
6
  import type { ReasoningEffort } from "../../reasoning-effort.js";
7
+ /** The participant's clock: its local time zone and its scheduled end, on the loop's clock. */
8
+ export interface ParticipantClock {
9
+ readonly timeZone?: string;
10
+ readonly end?: {
11
+ readonly atMs: number;
12
+ readonly windDownMs: number;
13
+ };
14
+ }
7
15
  /**
8
16
  * One desktop action. `heldKeys` on a pointer action are held down for the whole action and
9
17
  * released after it, named as the provider sent them (for example `SHIFT` for a shift-click). An
@@ -346,6 +354,14 @@ export interface CuaLoopOptions {
346
354
  dwell?: DwellWindow;
347
355
  /** Injected pause for the dwell window's cadence; tests advance their clock through it. */
348
356
  sleep?: (ms: number) => Promise<void>;
357
+ /**
358
+ * What each turn tells the participant about the time (loop/clock.ts). With `timeZone`, a time zone
359
+ * name, every request carries the local time. With `end`, from `end.atMs - end.windDownMs`
360
+ * every request names the end time (in `timeZone`, else UTC) and the minutes left, and at
361
+ * `end.atMs`, read on `now`, the loop asks for the closing report and ends the session with
362
+ * stop cause `scheduled_end`.
363
+ */
364
+ clock?: ParticipantClock;
349
365
  /**
350
366
  * The study's declared protocol: discrete tasks whose completion is corroborated by the
351
367
  * same observations stopWhen reads, on the same cadence. The tracker never influences the loop's
@@ -1,5 +1,5 @@
1
1
  import type { CuaLoopOptions, CuaLoopResult, LoopRunOptions } from "./loop/types.js";
2
- export type { CuaAction, CuaExecutor, CuaLiveMetadata, CuaLoopOptions, CuaLoopResult, CuaObservation, CuaProvider, CuaSafetyCheck, CuaSpendGate, CuaTurn, CuaTurnRequest, } from "./loop/types.js";
2
+ export type { CuaAction, CuaExecutor, CuaLiveMetadata, CuaLoopOptions, CuaLoopResult, CuaObservation, CuaProvider, CuaSafetyCheck, CuaSpendGate, CuaTurn, CuaTurnRequest, ParticipantClock, } from "./loop/types.js";
3
3
  export { describeCuaAction } from "./loop/actions.js";
4
4
  export { stableProgressKey } from "./loop/backstop.js";
5
5
  export { IMPRESSIONS_ASK } from "./loop/impressions.js";
@@ -1,7 +1,8 @@
1
1
  import { closingReportSchema } from "../closing-report.js";
2
2
  import { ComputerUseProviderError } from "./provider-error.js";
3
3
  import { runActionBatch } from "./loop/actions.js";
4
- import { advanceBackstop, startBackstop } from "./loop/backstop.js";
4
+ import { advanceBackstop, startBackstop, } from "./loop/backstop.js";
5
+ import { beforeScheduledEnd, clockHint, scheduledEndStop } from "./loop/clock.js";
5
6
  import { requestDebrief } from "./loop/debrief.js";
6
7
  import * as stops from "./loop/ending.js";
7
8
  import { declaredOutcomeOf } from "./loop/ending.js";
@@ -51,6 +52,7 @@ export async function runComputerUseLoopWithTaps(options) {
51
52
  previousExecution: undefined,
52
53
  acknowledgedSafetyChecks: undefined,
53
54
  contextHint: undefined,
55
+ askedToEnd: false,
54
56
  };
55
57
  let stop;
56
58
  try {
@@ -84,7 +86,7 @@ async function runTurns(session, conversation) {
84
86
  // A request whose reply was cut off by the output limit, to be sent once more.
85
87
  let resend;
86
88
  for (;;) {
87
- const halt = haltBeforeTurn(session);
89
+ const halt = haltBeforeTurn(session, observation);
88
90
  if (halt !== undefined)
89
91
  return halt;
90
92
  turnNumber += 1;
@@ -116,6 +118,13 @@ async function runTurns(session, conversation) {
116
118
  const blocked = reviewSafetyChecks(session, conversation, turn);
117
119
  if (blocked !== undefined)
118
120
  return blocked;
121
+ if ((turn.done || turn.actions.length === 0) && beforeScheduledEnd(session)) {
122
+ const held = keepWorking(session, conversation, backstop, observation);
123
+ if ("stop" in held)
124
+ return held.stop;
125
+ backstop = held.backstop;
126
+ continue;
127
+ }
119
128
  if (turn.done || turn.actions.length === 0) {
120
129
  // The declared outcome is kept even if redacting the participant's summary fails.
121
130
  session.declaredOutcome = declaredOutcomeOf(turn);
@@ -139,7 +148,12 @@ async function runTurns(session, conversation) {
139
148
  if ("stop" in checkpoint)
140
149
  return checkpoint.stop;
141
150
  observation = checkpoint.observation;
142
- const step = advanceBackstop(backstop, { actions: turn.actions, observation, heardNewSpeech: observer.heardNewSpeech }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
151
+ const step = advanceBackstop(backstop, {
152
+ actions: turn.actions,
153
+ observation,
154
+ heardNewSpeech: observer.heardNewSpeech,
155
+ waitingIsTheTask: beforeScheduledEnd(session),
156
+ }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
143
157
  backstop = step.backstop;
144
158
  const stalled = applyBackstop(session, conversation, step, [checkpoint.hint, batch.hint]);
145
159
  if (stalled !== undefined)
@@ -149,6 +163,20 @@ async function runTurns(session, conversation) {
149
163
  return unknownSpend;
150
164
  }
151
165
  }
166
+ /**
167
+ * A participant with a scheduled end that ended its own session before the end keeps going: the
168
+ * next request tells it the time left. The turn took no action, so it counts toward the idle
169
+ * backstop, which ends a participant that only keeps trying to stop.
170
+ */
171
+ function keepWorking(session, conversation, backstop, observation) {
172
+ session.trace.record("notice", () => notice("warn", "participant ended before its scheduled end", "The participant ended its session before its scheduled end, so the session continues and the participant is told the time left."));
173
+ conversation.askedToEnd = true;
174
+ // The turn executed nothing, so the next request acknowledges nothing.
175
+ conversation.previousExecution = undefined;
176
+ const step = advanceBackstop(backstop, { actions: [], observation, heardNewSpeech: false }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
177
+ const stalled = applyBackstop(session, conversation, step, []);
178
+ return stalled === undefined ? { backstop: step.backstop } : { stop: stalled };
179
+ }
152
180
  /**
153
181
  * Count the turn's idle and no-progress flags, stage every hint for the next request, and stop
154
182
  * when a streak tripped. The
@@ -173,15 +201,22 @@ function refuseAccountBilledCaps(options) {
173
201
  });
174
202
  }
175
203
  }
176
- function haltBeforeTurn(session) {
204
+ function haltBeforeTurn(session, observation) {
177
205
  if (session.signal?.aborted)
178
206
  return stops.harnessAborted;
179
- if (session.remaining() < 0)
180
- return stops.timeLimit(session);
181
- return undefined;
207
+ return (scheduledEndStop(session, observation) ??
208
+ (session.remaining() < 0 ? stops.timeLimit(session) : undefined));
182
209
  }
183
- /** Build the next request; the context hint and safety acknowledgements ride it once. */
210
+ /**
211
+ * Build the next request; the context hint and safety acknowledgements ride it once. The
212
+ * participant's clock text rides every request, after any other hint.
213
+ */
184
214
  function nextRequest(session, conversation, observation) {
215
+ const clock = clockHint(session.settings.clock, session.now, conversation.askedToEnd);
216
+ conversation.askedToEnd = false;
217
+ if (clock !== undefined)
218
+ conversation.contextHint =
219
+ conversation.contextHint === undefined ? clock : `${conversation.contextHint} ${clock}`;
185
220
  const request = {
186
221
  instructions: session.settings.instructions,
187
222
  observation,
@@ -50,12 +50,13 @@ export interface PlannedWait {
50
50
  export declare function defaultMaxWaitMs(speechEnabled: boolean): number;
51
51
  /**
52
52
  * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
53
- * in a study file, empty for loop options.
53
+ * in a study file, empty for loop options. `maxPrefix` names the owner of maxWaitMs when it is
54
+ * another, as for a participant's own idleWaitMs under actor.maxWaitMs.
54
55
  */
55
56
  export declare function waitFieldsReason(fields: {
56
57
  readonly maxWaitMs?: unknown;
57
58
  readonly idleWaitMs?: unknown;
58
- }, prefix: string): string | null;
59
+ }, prefix: string, maxPrefix?: string): string | null;
59
60
  /**
60
61
  * The wait lengths a session applies: the study's values, or their defaults. The idle wait never
61
62
  * outlasts the longest wait, which on a desktop with speech defaults to 30 s. Throws a RangeError
@@ -55,16 +55,17 @@ const FIELDS = [
55
55
  ];
56
56
  /**
57
57
  * Why these wait fields cannot run, or null. `prefix` names their owner in the message: `actor.`
58
- * in a study file, empty for loop options.
58
+ * in a study file, empty for loop options. `maxPrefix` names the owner of maxWaitMs when it is
59
+ * another, as for a participant's own idleWaitMs under actor.maxWaitMs.
59
60
  */
60
- export function waitFieldsReason(fields, prefix) {
61
+ export function waitFieldsReason(fields, prefix, maxPrefix = prefix) {
61
62
  for (const [name, meaning] of FIELDS) {
62
63
  if (fields[name] !== undefined && !isWaitMs(fields[name]))
63
- return `${prefix}${name}, ${meaning}, must be a whole number of milliseconds from ${CUA_WAIT_LIMITS.leastMs} to ${CUA_WAIT_LIMITS.mostMs}.`;
64
+ return `${name === "maxWaitMs" ? maxPrefix : prefix}${name}, ${meaning}, must be a whole number of milliseconds from ${CUA_WAIT_LIMITS.leastMs} to ${CUA_WAIT_LIMITS.mostMs}.`;
64
65
  }
65
66
  const { maxWaitMs, idleWaitMs } = fields;
66
67
  if (typeof maxWaitMs === "number" && typeof idleWaitMs === "number" && idleWaitMs > maxWaitMs)
67
- return `${prefix}idleWaitMs (${idleWaitMs}) is longer than ${prefix}maxWaitMs (${maxWaitMs}), the longest one wait action lasts. Lower ${prefix}idleWaitMs or raise ${prefix}maxWaitMs.`;
68
+ return `${prefix}idleWaitMs (${idleWaitMs}) is longer than ${maxPrefix}maxWaitMs (${maxWaitMs}), the longest one wait action lasts. Lower ${prefix}idleWaitMs or raise ${maxPrefix}maxWaitMs.`;
68
69
  return null;
69
70
  }
70
71
  /**
@@ -37,7 +37,7 @@ export type ParticipantDeclaredOutcome = "reached" | "not_reached" | "blocked";
37
37
  export declare const PARTICIPANT_OUTCOME_STATUSES: readonly ActorStatus[];
38
38
  /** Optional precise interruption cause; completionReason and status retain their original meaning.
39
39
  * One list, so the analysis schema and the diagnostics projection cannot drift from the trace. */
40
- export declare const ACTOR_STOP_CAUSES: readonly ["provider_output_limit", "provider_token_limit", "time_limit", "spend_limit", "study_spend_limit", "adapter_limit", "provider_incomplete", "provider_status", "provider_refused_prompt", "harness_aborted", "usage_unreported"];
40
+ export declare const ACTOR_STOP_CAUSES: readonly ["provider_output_limit", "provider_token_limit", "time_limit", "spend_limit", "study_spend_limit", "adapter_limit", "provider_incomplete", "provider_status", "provider_refused_prompt", "harness_aborted", "usage_unreported", "scheduled_end"];
41
41
  export type ActorStopCause = (typeof ACTOR_STOP_CAUSES)[number];
42
42
  export type ActorCompletionReason = "goal_satisfied" | "turn_completed" | "gave_up" | "blocked_approval" | "timed_out" | "budget_reached" | "actor_error" | "step_failed" | "harness_error";
43
43
  type ActorRunKind = "code" | "computer-use" | "scripted-browser" | "terminal";
@@ -346,11 +346,11 @@ export interface ActorTrace {
346
346
  declaredOutcome?: ParticipantDeclaredOutcome;
347
347
  /**
348
348
  * The read-only closing request: a closing report after a harness-owned stop (`stop_when`,
349
- * `dwell`), or impressions only after the participant ended the session itself
349
+ * `dwell`, `scheduled_end`), or impressions only after the participant ended the session itself
350
350
  * (`participant_end`, never with `report`). Does not change task outcomes or permit actions.
351
351
  */
352
352
  debrief?: {
353
- trigger: "stop_when" | "dwell" | "participant_end";
353
+ trigger: "stop_when" | "dwell" | "scheduled_end" | "participant_end";
354
354
  status: "completed" | "skipped" | "failed";
355
355
  reason: string;
356
356
  /** Absent if no request was made; false means token accounting is incomplete. */
@@ -53,6 +53,9 @@ export const ACTOR_STOP_CAUSES = [
53
53
  "provider_refused_prompt",
54
54
  "harness_aborted",
55
55
  "usage_unreported",
56
+ // The participant's scheduled end (participants[].endAfterMs) came. A planned end: the session
57
+ // is goal_satisfied, and the closing report follows.
58
+ "scheduled_end",
56
59
  ];
57
60
  /**
58
61
  * The oldest Codex CLI release a recorded account profile may name. Readers accept every stable
@@ -10,6 +10,7 @@ const labels = {
10
10
  provider_status: "unexpected provider status",
11
11
  provider_refused_prompt: "provider refused the prompt under its usage policy",
12
12
  harness_aborted: "stopped by harness",
13
+ scheduled_end: "scheduled end",
13
14
  unspecified_limit: "limit reached",
14
15
  };
15
16
  /** Shared wording for consumers that already hold a validated, finite stop cause. */
@@ -4,6 +4,7 @@ import {} from "../run/bundle.js";
4
4
  import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalysisRun, } from "./service.js";
5
5
  import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
6
6
  import { captureEvidence } from "./evidence.js";
7
+ import { loadRunTraces } from "../run/stream-trace.js";
7
8
  import { hashAnalysisValue } from "./validation.js";
8
9
  import { refusedCost } from "./admission.js";
9
10
  import { claimAutomaticAnalysis, readAutomaticAnalysisPrepared, requestAutomaticAnalysisCancellationPrepared, } from "./job.js";
@@ -88,7 +89,7 @@ async function readAutomaticSource(cwd, prepared, configInput, deps, hasKey) {
88
89
  let config = configInput;
89
90
  try {
90
91
  const bytes = await readCompletedAnalysisSource(cwd, prepared);
91
- const bundle = JSON.parse(bytes.toString("utf8"));
92
+ const bundle = await loadRunTraces(prepared, JSON.parse(bytes.toString("utf8")));
92
93
  if (bundle.streams.some((stream) => stream.actor?.stopCause === "harness_aborted"))
93
94
  return skipped("AUTOMATIC_ANALYSIS_ACTOR_CANCELLED");
94
95
  const participantEvidence = hasParticipantEvidence(bundle);