humanish 0.125.0 → 0.126.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 (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  5. package/dist/actors/computer-use/loop/clock.js +27 -17
  6. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  7. package/dist/actors/computer-use/loop/debrief.js +15 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  9. package/dist/actors/computer-use/loop/ending.js +8 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  11. package/dist/actors/computer-use/loop/observation.js +19 -1
  12. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  13. package/dist/actors/computer-use/loop/session.js +4 -1
  14. package/dist/actors/computer-use/loop/trace.js +4 -2
  15. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  16. package/dist/actors/computer-use/loop.js +5 -5
  17. package/dist/actors/contract.d.ts +31 -8
  18. package/dist/actors/contract.js +3 -0
  19. package/dist/actors/stop-cause.js +1 -0
  20. package/dist/cli/commands/study.js +8 -0
  21. package/dist/evidence/redaction.d.ts +1 -0
  22. package/dist/evidence/redaction.js +1 -0
  23. package/dist/evidence/trace-page.d.ts +6 -0
  24. package/dist/evidence/trace-page.js +90 -0
  25. package/dist/observer/feed-cursors.d.ts +14 -0
  26. package/dist/observer/feed-cursors.js +61 -0
  27. package/dist/observer/participant-items.d.ts +7 -0
  28. package/dist/observer/pinned-files.d.ts +2 -1
  29. package/dist/observer/pinned-files.js +2 -2
  30. package/dist/observer/run-routes.d.ts +1 -1
  31. package/dist/observer/run-routes.js +38 -17
  32. package/dist/observer-app.html +7 -7
  33. package/dist/routes/computer-use/bundle.js +4 -0
  34. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  35. package/dist/routes/computer-use/diagnostics.js +3 -3
  36. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  37. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  38. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  39. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  40. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  41. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  42. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  43. package/dist/routes/computer-use/fanout-records.js +4 -0
  44. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  45. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  46. package/dist/routes/computer-use/participant-model.js +16 -6
  47. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  48. package/dist/routes/computer-use/participant-runs.js +11 -7
  49. package/dist/routes/computer-use/plan.js +39 -1
  50. package/dist/routes/computer-use/setup.d.ts +1 -0
  51. package/dist/routes/computer-use/setup.js +28 -0
  52. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  53. package/dist/routes/computer-use/single-bundle.js +2 -0
  54. package/dist/routes/computer-use/types.d.ts +16 -10
  55. package/dist/routes/shared-world/bundle-records.js +3 -0
  56. package/dist/routes/shared-world/participant-specs.js +3 -2
  57. package/dist/routes/shared-world/plan.js +5 -0
  58. package/dist/run/contained-output.d.ts +7 -3
  59. package/dist/run/contained-output.js +11 -7
  60. package/dist/run/judge.d.ts +7 -4
  61. package/dist/run/judge.js +13 -8
  62. package/dist/run/live-items.d.ts +19 -0
  63. package/dist/run/live-items.js +28 -8
  64. package/dist/run/stream-trace.d.ts +25 -4
  65. package/dist/run/stream-trace.js +55 -17
  66. package/dist/run/streams.d.ts +16 -0
  67. package/dist/study/arrivals.d.ts +32 -14
  68. package/dist/study/arrivals.js +47 -21
  69. package/dist/study/bundle-estimate.d.ts +6 -5
  70. package/dist/study/bundle-estimate.js +12 -7
  71. package/dist/study/desktop-files.d.ts +37 -0
  72. package/dist/study/desktop-files.js +174 -0
  73. package/dist/study/keys.d.ts +8 -0
  74. package/dist/study/keys.js +3 -0
  75. package/dist/study/migrate/v2.js +12 -3
  76. package/dist/study/parse/actors.d.ts +1 -1
  77. package/dist/study/parse/actors.js +28 -20
  78. package/dist/study/parse/clock.d.ts +18 -0
  79. package/dist/study/parse/clock.js +36 -0
  80. package/dist/study/plan-participants.d.ts +19 -5
  81. package/dist/study/plan-participants.js +17 -1
  82. package/dist/study/plan.js +24 -13
  83. package/dist/study/preflight.js +4 -0
  84. package/dist/study/types.d.ts +34 -5
  85. package/dist/study/validation.js +3 -0
  86. package/dist/study/warnings.d.ts +2 -2
  87. package/dist/study/warnings.js +7 -0
  88. package/dist/subject/local-tree-archive.d.ts +7 -0
  89. package/dist/subject/local-tree-archive.js +12 -0
  90. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  91. package/dist/substrates/e2b/desktop-browser.js +6 -2
  92. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  93. package/dist/substrates/e2b/desktop-clock.js +29 -0
  94. package/dist/substrates/e2b/sdk.d.ts +6 -0
  95. package/dist/verify/pages.d.ts +7 -0
  96. package/dist/verify/pages.js +50 -0
  97. package/dist/verify/verify.js +10 -3
  98. package/docs/architecture/actor-contract.md +2 -0
  99. package/docs/architecture/external-public-shared-world.md +4 -1
  100. package/docs/architecture/observer.md +10 -1
  101. package/docs/contracts/policy.md +7 -0
  102. package/docs/contracts/run-bundle.md +31 -1
  103. package/docs/contracts/schemas.md +72 -3
  104. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.126.0: Per-participant time zones, session caps and desktop files, page paths, a lighter live Observer (2026-10-13)
12
+
13
+ humanish 0.126.0 adds per-participant settings for long, mixed studies and a lighter live Observer.
14
+ Each participant can have its own time zone (`participants[].clock`), which now also sets its
15
+ hosted browser, its desktop's panel clock and a `desktop-cli` terminal; an existing
16
+ `actor.clock.timeZone` sets them too. `participants[].maxSessionMs` lets a participant end its
17
+ session sooner and stops one still working at the cap with a closing report.
18
+ `participants[].files` puts files on a participant's desktop for upload dialogs, and each
19
+ computer-use turn records the page it was on (origin and path only). A closing report that opens
20
+ "REACHED THE GOAL." no longer fails its run over the page copy it paraphrases. The served Observer's
21
+ live feed sends only new items each poll: about 90 KB per poll through a synthetic 8-hour,
22
+ 24-participant run, where 0.125.0 sent up to 69.5 MB. Offsets and miscased zone names are now
23
+ refused, and so is a zone on the local browser runtime.
24
+
25
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.126.0)
26
+
11
27
  ## 0.125.0: Run bundle v3 with live items in append-only files, loadRunBundle, faster repeated verify (2026-10-12)
12
28
 
13
29
  humanish 0.125.0 keeps each live flush of a long study small, exports the run bundle loader, and
@@ -78,6 +78,8 @@ export interface CuaActorSessionOptions {
78
78
  onMessage?: (text: string) => void;
79
79
  /** Runtime-only per-turn raw-frame callback threaded to the loop; see LoopTaps.onScreenshot. */
80
80
  onScreenshot?: (frame: Buffer) => void;
81
+ /** Whether the trace carries the observed page, threaded to the loop; see LoopRunOptions.recordPages. */
82
+ recordPages?: boolean;
81
83
  /** Per-turn trace snapshot callback threaded to the loop; see CuaLoopOptions.onTrace. */
82
84
  onTrace?: (items: readonly ActorTraceItem[], usage: ActorTokenUsage, metadata?: CuaLiveMetadata) => void;
83
85
  }
@@ -57,6 +57,7 @@ export async function runCuaActorSession(options) {
57
57
  ...(options.onMessage === undefined ? {} : { onMessage: options.onMessage }),
58
58
  ...(options.onScreenshot === undefined ? {} : { onScreenshot: options.onScreenshot }),
59
59
  ...(options.onTrace === undefined ? {} : { onTrace: options.onTrace }),
60
+ ...(options.recordPages === undefined ? {} : { recordPages: options.recordPages }),
60
61
  };
61
62
  const result = await runComputerUseLoopWithTaps(loopOptions);
62
63
  if (result.trace.executionProfile?.billing === "account-unknown") {
@@ -4,17 +4,18 @@ import type { CuaObservation, ParticipantClock } from "./types.js";
4
4
  /**
5
5
  * The time text for the request the loop is about to send: the local time when the participant
6
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.
7
+ * after it tried to end its session before its scheduled end (`askedToEnd`). Undefined when
8
+ * there is nothing to say.
9
9
  */
10
10
  export declare function clockHint(clock: ParticipantClock | undefined, now: () => number, askedToEnd?: boolean): string | undefined;
11
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.
12
+ * Whether the participant is held to a scheduled end still to come. Its own end before then does
13
+ * not end the session: the end is what the study declared for it. A session cap holds no one.
14
14
  */
15
- export declare function beforeScheduledEnd(session: LoopSession): boolean;
15
+ export declare function heldToScheduledEnd(session: LoopSession): boolean;
16
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.
17
+ * The stop at the participant's end, or undefined before it: its scheduled end or its session cap,
18
+ * which is the stop cause. The session passes as a planned end and the participant is asked for
19
+ * its closing report on the last observation.
19
20
  */
20
- export declare function scheduledEndStop(session: LoopSession, observation: CuaObservation): Stop | undefined;
21
+ export declare function clockEndStop(session: LoopSession, observation: CuaObservation): Stop | undefined;
@@ -1,13 +1,14 @@
1
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).
2
+ // What the participant knows about the time. A hosted desktop's panel clock shows the time in the
3
+ // participant's zone, or in UTC without one, and a local browser shows no clock. Neither shows
4
+ // when the session ends, so a participant told "your session ends at 11:00" relates it to now
5
+ // through each turn's text. The text rides the request's context hint, which every provider sends
6
+ // with the turn (OpenAI as a user message, Codex and the local agents as the harness note).
6
7
  /**
7
8
  * The time text for the request the loop is about to send: the local time when the participant
8
9
  * 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.
10
+ * after it tried to end its session before its scheduled end (`askedToEnd`). Undefined when
11
+ * there is nothing to say.
11
12
  */
12
13
  export function clockHint(clock, now, askedToEnd = false) {
13
14
  if (clock === undefined)
@@ -19,31 +20,40 @@ export function clockHint(clock, now, askedToEnd = false) {
19
20
  const { end } = clock;
20
21
  if (end !== undefined && (askedToEnd || nowMs >= end.atMs - end.windDownMs)) {
21
22
  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
+ const at = `${endTime(clock, end.atMs)}${end.kind === "session_cap" ? " at the latest" : ""}`;
24
+ const close = end.kind === "session_cap"
25
+ ? "You may end it sooner when you are done. If you are still working then, it ends by itself, and you will then be asked for your closing account."
26
+ : "Keep working until then. The session ends by itself at that time, and you will then be asked for your closing account.";
27
+ parts.push(`${askedToEnd ? "Your session is not over. " : ""}Your session ends at ${at}, in ${plural(minutesLeft, "minute")}. ${close}`);
23
28
  }
24
29
  return parts.length === 0 ? undefined : parts.join(" ");
25
30
  }
26
31
  /**
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.
32
+ * Whether the participant is held to a scheduled end still to come. Its own end before then does
33
+ * not end the session: the end is what the study declared for it. A session cap holds no one.
29
34
  */
30
- export function beforeScheduledEnd(session) {
35
+ export function heldToScheduledEnd(session) {
31
36
  const end = session.settings.clock?.end;
32
- return end !== undefined && session.now() < end.atMs;
37
+ return end !== undefined && end.kind !== "session_cap" && session.now() < end.atMs;
33
38
  }
34
39
  /**
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.
40
+ * The stop at the participant's end, or undefined before it: its scheduled end or its session cap,
41
+ * which is the stop cause. The session passes as a planned end and the participant is asked for
42
+ * its closing report on the last observation.
37
43
  */
38
- export function scheduledEndStop(session, observation) {
44
+ export function clockEndStop(session, observation) {
39
45
  const end = session.settings.clock?.end;
40
46
  if (end === undefined || session.now() < end.atMs)
41
47
  return undefined;
48
+ const at = endTime(session.settings.clock, end.atMs);
49
+ const kind = end.kind ?? "scheduled_end";
42
50
  return {
43
51
  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 },
52
+ reason: kind === "session_cap"
53
+ ? `the participant's session reached its cap at ${at}`
54
+ : `the participant's scheduled end at ${at} was reached`,
55
+ stopCause: kind,
56
+ debriefTrigger: { kind, observation },
47
57
  };
48
58
  }
49
59
  // The end to the nearest minute: a run starts at no particular second, and "ends at 00:09" read
@@ -1,5 +1,5 @@
1
1
  import type { ActorTrace } from "../../contract.js";
2
- import type { DebriefTrigger } from "./ending.js";
2
+ import { type DebriefTrigger } from "./ending.js";
3
3
  import type { LoopSession } from "./session.js";
4
4
  import type { CuaTurnRequest } from "./types.js";
5
5
  type Debrief = NonNullable<ActorTrace["debrief"]>;
@@ -1,6 +1,7 @@
1
1
  import { closingReportSchema, participantImpressionsSchema } from "../../closing-report.js";
2
2
  import { isComputerUseAdmissionLimitError } from "../admission-limit.js";
3
3
  import { IMPRESSIONS_ASK, IMPRESSIONS_HINT, recordImpressions } from "./impressions.js";
4
+ import { declaredOutcomeOf } from "./ending.js";
4
5
  import { singleDispatch } from "./provider-call.js";
5
6
  import { CuaDeadlineError, raceSessionDeadline, requestScope } from "./race.js";
6
7
  import { notice } from "./trace.js";
@@ -109,7 +110,7 @@ async function debriefExchange(session, context, trigger, ask, { send, boundMs }
109
110
  const turn = provider.requestPolicy === "fail_closed"
110
111
  ? await singleDispatch(session, "debrief", (dispatchSignal) => send(request, dispatchSignal), boundMs)
111
112
  : await raceSessionDeadline(send(request, scope.signal), boundMs, signal);
112
- return acceptDebriefTurn(session, turn, ask, record);
113
+ return acceptDebriefTurn(session, turn, trigger, ask, record);
113
114
  }
114
115
  catch (error) {
115
116
  // A failed optional report cannot rewrite the already observed structured completion.
@@ -132,7 +133,7 @@ async function debriefExchange(session, context, trigger, ask, { send, boundMs }
132
133
  scope.end();
133
134
  }
134
135
  }
135
- function acceptDebriefTurn(session, turn, ask, record) {
136
+ function acceptDebriefTurn(session, turn, trigger, ask, record) {
136
137
  const { maxUsd, overRunBudget, estimateTurnCostUsd } = session.settings;
137
138
  session.usage.record(turn, "debrief");
138
139
  session.lastResponseId = turn.responseId ?? session.lastResponseId;
@@ -148,11 +149,18 @@ function acceptDebriefTurn(session, turn, ask, record) {
148
149
  return record("failed", "the closing response requested actions or safety checks; none were executed and its report was not accepted", usageReported);
149
150
  }
150
151
  // First-party providers parse their replies already; these parses cover third-party ports.
151
- const { status, detail, ...added } = ask.read(session, turn);
152
+ const { status, detail, ...added } = ask.read(session, turn, trigger);
152
153
  return { ...record(status, detail, usageReported), ...added };
153
154
  }
154
- /** A closing report: its summary and friction as one participant message, then its impressions. */
155
- function readClosingReport(session, turn) {
155
+ /**
156
+ * A closing report: its summary and friction as one participant message, then its impressions.
157
+ * At a scheduled end or a session cap an accepted report is the participant's account of the
158
+ * session: its summary becomes the session's reason (loopResult), so the outcome it declares
159
+ * becomes the session's declared outcome. The blocker rule reads a declared `reached` or
160
+ * `blocked` before the summary's wording. After a matched stop condition or dwell window, the
161
+ * condition stays the completion evidence.
162
+ */
163
+ function readClosingReport(session, turn, trigger) {
156
164
  const closing = closingReportSchema.safeParse(turn.closingReport);
157
165
  if (!closing.success) {
158
166
  return {
@@ -173,6 +181,8 @@ function readClosingReport(session, turn) {
173
181
  }));
174
182
  session.trace.bump("messages");
175
183
  session.impressions = recordImpressions(session, closing.data.impressions);
184
+ if (trigger.kind === "scheduled_end" || trigger.kind === "session_cap")
185
+ session.declaredOutcome = declaredOutcomeOf(turn, closing.data.summary);
176
186
  return {
177
187
  status: "completed",
178
188
  detail: "one read-only report; no additional desktop actions; original stop and task outcomes preserved",
@@ -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" | "scheduled_end" | "participant_end";
11
+ readonly kind: "stop_when" | "dwell" | "scheduled_end" | "session_cap" | "participant_end";
12
12
  /** The final observation, already captured; the debrief request sends it without observing. */
13
13
  readonly observation: CuaObservation;
14
14
  }
@@ -22,12 +22,15 @@ export interface Stop {
22
22
  }
23
23
  /**
24
24
  * Read the fixed closing line the prompt asks for: the first non-empty line of the
25
- * participant's last message, exactly one of three phrases, punctuation and case forgiven. Anything
26
- * else is absence, never a guess. Exported for tests.
25
+ * participant's last message or closing report summary, exactly one of three phrases,
26
+ * punctuation and case forgiven. Anything else is absence, never a guess. Exported for tests.
27
27
  */
28
28
  export declare function declaredOutcomeFromClosingLine(message: string | undefined): ParticipantDeclaredOutcome | undefined;
29
- /** The participant's own word for how it ended: a schema field first, then the closing line. */
30
- export declare function declaredOutcomeOf(turn: CuaTurn): ParticipantDeclaredOutcome | undefined;
29
+ /**
30
+ * The participant's own word for how it ended: the turn's schema field first, then the closing
31
+ * line of `text`, its last message or its closing report's summary.
32
+ */
33
+ export declare function declaredOutcomeOf(turn: CuaTurn, text: string | undefined): ParticipantDeclaredOutcome | undefined;
31
34
  /**
32
35
  * The participant reported a natural endpoint. "not_reached" is a participant who stopped without
33
36
  * finishing: gave_up, which tallies as abandoned. "blocked" keeps goal_satisfied here (the actor
@@ -6,8 +6,8 @@ import { notice } from "./trace.js";
6
6
  import { plural } from "../../../run/text.js";
7
7
  /**
8
8
  * Read the fixed closing line the prompt asks for: the first non-empty line of the
9
- * participant's last message, exactly one of three phrases, punctuation and case forgiven. Anything
10
- * else is absence, never a guess. Exported for tests.
9
+ * participant's last message or closing report summary, exactly one of three phrases,
10
+ * punctuation and case forgiven. Anything else is absence, never a guess. Exported for tests.
11
11
  */
12
12
  export function declaredOutcomeFromClosingLine(message) {
13
13
  if (message === undefined)
@@ -27,9 +27,12 @@ export function declaredOutcomeFromClosingLine(message) {
27
27
  return "blocked";
28
28
  return undefined;
29
29
  }
30
- /** The participant's own word for how it ended: a schema field first, then the closing line. */
31
- export function declaredOutcomeOf(turn) {
32
- return turn.outcome ?? declaredOutcomeFromClosingLine(turn.message);
30
+ /**
31
+ * The participant's own word for how it ended: the turn's schema field first, then the closing
32
+ * line of `text`, its last message or its closing report's summary.
33
+ */
34
+ export function declaredOutcomeOf(turn, text) {
35
+ return turn.outcome ?? declaredOutcomeFromClosingLine(text);
33
36
  }
34
37
  /**
35
38
  * The participant reported a natural endpoint. "not_reached" is a participant who stopped without
@@ -43,7 +43,13 @@ export declare class DesktopObserver {
43
43
  * session is already over.
44
44
  */
45
45
  observeFinalTasks(turnNumber: number): Promise<void>;
46
+ /**
47
+ * Observe the desktop and note the observation's page as the session's final page. Every
48
+ * observation passes through here, so the page recordScreenshot puts on an item is the page of
49
+ * the observation it records.
50
+ */
46
51
  private observeBounded;
52
+ private observeOrRetry;
47
53
  /**
48
54
  * The observation with every utterance heard since the provider last saw one. New utterances are
49
55
  * recorded as notices; too many pending utterances fail the executor as invalid_response.
@@ -1,6 +1,7 @@
1
1
  import { evaluateStopWhen } from "../../stop-conditions.js";
2
2
  import { ComputerUseExecutorError, isComputerUseExecutorError } from "../executor-error.js";
3
3
  import { CUA_SPEECH_LIMITS } from "../speech.js";
4
+ import { tracePage } from "../../../evidence/trace-page.js";
4
5
  import { dwellCompleted, missingFrame, stopWhenMatched } from "./ending.js";
5
6
  import { CuaAbortError, CuaStallError, raceCallBound } from "./race.js";
6
7
  import { notice } from "./trace.js";
@@ -119,10 +120,24 @@ export class DesktopObserver {
119
120
  throw error;
120
121
  }
121
122
  }
123
+ /**
124
+ * Observe the desktop and note the observation's page as the session's final page. Every
125
+ * observation passes through here, so the page recordScreenshot puts on an item is the page of
126
+ * the observation it records.
127
+ */
128
+ async observeBounded(label) {
129
+ const observation = await this.observeOrRetry(label);
130
+ const { session } = this;
131
+ session.finalPage =
132
+ session.settings.recordPages === false
133
+ ? undefined
134
+ : tracePage(observation.url, (text) => session.redactNarration(text));
135
+ return observation;
136
+ }
122
137
  // An executor without stallRecovery "fail_closed" gets one retry of a stalled observe.
123
138
  // One that cannot safely replay a pending request opts out, so even a shorter outer bound stops
124
139
  // without retrying.
125
- async observeBounded(label) {
140
+ async observeOrRetry(label) {
126
141
  const { session } = this;
127
142
  const { executor } = session;
128
143
  try {
@@ -179,10 +194,13 @@ export class DesktopObserver {
179
194
  const path = await session.writeScreenshot(`${label}.png`, bytes);
180
195
  const screenshotRef = { path, redaction: method };
181
196
  session.lastScreenshotRef = screenshotRef;
197
+ const page = session.finalPage;
182
198
  session.trace.record("screenshot", () => ({
183
199
  lifecycle: "completed",
184
200
  title: label,
185
201
  screenshotRef,
202
+ // A copy: the live flush freezes a recorded item, and finalPage stays the session's own.
203
+ ...(page === undefined ? {} : { page: { ...page } }),
186
204
  }));
187
205
  session.trace.bump("screenshots");
188
206
  }
@@ -1,4 +1,4 @@
1
- import type { ActorTraceItem, ParticipantDeclaredOutcome, ParticipantImpressions } from "../../contract.js";
1
+ import type { ActorTraceItem, ParticipantDeclaredOutcome, ParticipantImpressions, TracePage } from "../../contract.js";
2
2
  import type { AffordanceObservation } from "../../affordance.js";
3
3
  import { TaskTracker } from "../../../study/tasks.js";
4
4
  import type { DebriefTrigger, Stop } from "./ending.js";
@@ -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" | "clock" | "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" | "recordPages"]: LoopRunOptions[K];
16
16
  };
17
17
  /** The participant's dispatched actions, as the backstop and failure notices describe them. */
18
18
  export interface ActionHistory {
@@ -61,6 +61,8 @@ export declare class LoopSession {
61
61
  lastScreenshotRef: ScreenshotRef | undefined;
62
62
  lastResponseId: string | undefined;
63
63
  observedAppState: boolean;
64
+ /** The page of the session's last observation; undefined when that observation read no URL. */
65
+ finalPage: TracePage | undefined;
64
66
  declaredOutcome: ParticipantDeclaredOutcome | undefined;
65
67
  /** Set by a structured stop; the debrief follows it even if the session then failed. */
66
68
  debriefTrigger: DebriefTrigger | undefined;
@@ -61,6 +61,8 @@ export class LoopSession {
61
61
  // Whether any observation carried appState. trace.ts states it in redaction.notes; the appState
62
62
  // itself is never persisted.
63
63
  observedAppState = false;
64
+ /** The page of the session's last observation; undefined when that observation read no URL. */
65
+ finalPage;
64
66
  declaredOutcome;
65
67
  /** Set by a structured stop; the debrief follows it even if the session then failed. */
66
68
  debriefTrigger;
@@ -70,7 +72,7 @@ export class LoopSession {
70
72
  scrubText;
71
73
  constructor(options) {
72
74
  // 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, clock, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tasks, maxUsd, overRunBudget, estimateTurnCostUsd, requireReportedUsageForSpendCap = false, onObservedUrl, onMessage, onScreenshot, onTrace, } = options;
75
+ 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, recordPages, } = options;
74
76
  this.settings = {
75
77
  instructions,
76
78
  persona,
@@ -86,6 +88,7 @@ export class LoopSession {
86
88
  onMessage,
87
89
  onScreenshot,
88
90
  onTrace,
91
+ recordPages,
89
92
  };
90
93
  this.provider = provider;
91
94
  this.executor = executor;
@@ -67,8 +67,9 @@ export function loopResult(session, stop, debrief) {
67
67
  const { provider, settings, trace: recorder, usage } = session;
68
68
  const { completionReason, stopCause } = stop;
69
69
  // A participant whose session ended on the clock has its closing account as its reason, as one
70
- // that ended the session itself has its last message; the stop cause keeps the scheduled end.
71
- const reason = stopCause === "scheduled_end" && debrief?.report !== undefined
70
+ // that ended the session itself has its last message; the stop cause keeps the scheduled end or
71
+ // the session cap.
72
+ const reason = (stopCause === "scheduled_end" || stopCause === "session_cap") && debrief?.report !== undefined
72
73
  ? debrief.report.summary
73
74
  : stop.reason;
74
75
  const completedAtMs = session.now();
@@ -131,6 +132,7 @@ export function loopResult(session, stop, debrief) {
131
132
  waitSettings: { ...session.waits },
132
133
  counts,
133
134
  items: recorder.items,
135
+ ...(session.finalPage === undefined ? {} : { finalPage: session.finalPage }),
134
136
  ...(session.actionHistory.affordances.length > 0
135
137
  ? { affordanceUse: summarizeAffordanceUse(session.actionHistory.affordances) }
136
138
  : {}),
@@ -4,12 +4,18 @@ 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. */
7
+ /** The participant's clock: its local time zone and its end, on the loop's clock. */
8
8
  export interface ParticipantClock {
9
9
  readonly timeZone?: string;
10
+ /**
11
+ * When the session ends. `kind` is `scheduled_end` when absent: the participant is held to the
12
+ * end and may not end its session before it. A `session_cap` is the latest the session lasts:
13
+ * the participant may end it sooner.
14
+ */
10
15
  readonly end?: {
11
16
  readonly atMs: number;
12
17
  readonly windDownMs: number;
18
+ readonly kind?: "scheduled_end" | "session_cap";
13
19
  };
14
20
  }
15
21
  /**
@@ -94,9 +100,10 @@ export interface CuaObservation {
94
100
  appState?: Record<string, unknown>;
95
101
  /**
96
102
  * Optional browser state captured by an executor that can inspect the driven browser
97
- * deterministically (for example via Chrome DevTools Protocol). These fields are runtime-only:
98
- * they may drive stopWhen and progress decisions, but the loop never persists raw URL/title/text
99
- * into the trace. Persisting arbitrary DOM text would make private-data leakage too easy.
103
+ * deterministically (for example via Chrome DevTools Protocol). These fields drive stopWhen and
104
+ * progress decisions. The loop never persists the raw URL, title or text: persisting arbitrary
105
+ * DOM text would make private-data leakage too easy. From the URL it records only the page's
106
+ * origin and redacted path (`page` on screenshot items, `finalPage`; src/evidence/trace-page.ts).
100
107
  */
101
108
  url?: string;
102
109
  /**
@@ -359,7 +366,7 @@ export interface CuaLoopOptions {
359
366
  * name, every request carries the local time. With `end`, from `end.atMs - end.windDownMs`
360
367
  * every request names the end time (in `timeZone`, else UTC) and the minutes left, and at
361
368
  * `end.atMs`, read on `now`, the loop asks for the closing report and ends the session with
362
- * stop cause `scheduled_end`.
369
+ * stop cause `end.kind` (`scheduled_end` when absent).
363
370
  */
364
371
  clock?: ParticipantClock;
365
372
  /**
@@ -421,7 +428,7 @@ export interface LoopTaps {
421
428
  /**
422
429
  * Runtime-only URL tap: called with `observation.url` for each observation a turn may receive
423
430
  * (the initial one, each post-action one and the one after a dwell window). The loop never
424
- * persists the URL. Default: no-op.
431
+ * persists the raw URL. Default: no-op.
425
432
  *
426
433
  * Built-in caller: the external-public shared-world handoff (src/routes/shared-world/handoff.ts)
427
434
  * latches the host participant's lobby URL and checks that each follower reached the same lobby.
@@ -447,8 +454,15 @@ export interface LoopTaps {
447
454
  */
448
455
  onScreenshot?: (frame: Buffer) => void;
449
456
  }
450
- /** The loop's options inside humanish: the public ones plus the handoff's taps. */
451
- export type LoopRunOptions = CuaLoopOptions & LoopTaps;
457
+ /** The loop's options inside humanish: the public ones, the handoff's taps and the page switch. */
458
+ export type LoopRunOptions = CuaLoopOptions & LoopTaps & {
459
+ /**
460
+ * Whether screenshot items and the trace carry the observed page (`page`, `finalPage`).
461
+ * Default true. The shared-world route passes false: its observed URLs land in the bundle only
462
+ * as digests (docs/architecture/external-public-shared-world.md).
463
+ */
464
+ recordPages?: boolean;
465
+ };
452
466
  export type CuaLiveMetadata = Pick<ActorTrace, "executionProfile" | "providerRequests" | "historyTurnsOmitted">;
453
467
  /** What runComputerUseLoop returns; the public name for an actor session result. */
454
468
  export type CuaLoopResult = ActorSessionResult;
@@ -2,7 +2,7 @@ import { closingReportSchema } from "../closing-report.js";
2
2
  import { ComputerUseProviderError } from "./provider-error.js";
3
3
  import { runActionBatch } from "./loop/actions.js";
4
4
  import { advanceBackstop, startBackstop, } from "./loop/backstop.js";
5
- import { beforeScheduledEnd, clockHint, scheduledEndStop } from "./loop/clock.js";
5
+ import { clockEndStop, clockHint, heldToScheduledEnd } from "./loop/clock.js";
6
6
  import { requestDebrief } from "./loop/debrief.js";
7
7
  import * as stops from "./loop/ending.js";
8
8
  import { declaredOutcomeOf } from "./loop/ending.js";
@@ -118,7 +118,7 @@ async function runTurns(session, conversation) {
118
118
  const blocked = reviewSafetyChecks(session, conversation, turn);
119
119
  if (blocked !== undefined)
120
120
  return blocked;
121
- if ((turn.done || turn.actions.length === 0) && beforeScheduledEnd(session)) {
121
+ if ((turn.done || turn.actions.length === 0) && heldToScheduledEnd(session)) {
122
122
  const held = keepWorking(session, conversation, backstop, observation);
123
123
  if ("stop" in held)
124
124
  return held.stop;
@@ -127,7 +127,7 @@ async function runTurns(session, conversation) {
127
127
  }
128
128
  if (turn.done || turn.actions.length === 0) {
129
129
  // The declared outcome is kept even if redacting the participant's summary fails.
130
- session.declaredOutcome = declaredOutcomeOf(turn);
130
+ session.declaredOutcome = declaredOutcomeOf(turn, turn.message);
131
131
  const ended = stops.participantEnded(turn, session.declaredOutcome, (text) => session.redactNarration(text));
132
132
  const report = closingReportSchema.safeParse(turn.closingReport).data;
133
133
  // A provider that can ask for impressions alone does so after the session, like a debrief.
@@ -152,7 +152,7 @@ async function runTurns(session, conversation) {
152
152
  actions: turn.actions,
153
153
  observation,
154
154
  heardNewSpeech: observer.heardNewSpeech,
155
- waitingIsTheTask: beforeScheduledEnd(session),
155
+ waitingIsTheTask: heldToScheduledEnd(session),
156
156
  }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
157
157
  backstop = step.backstop;
158
158
  const stalled = applyBackstop(session, conversation, step, [checkpoint.hint, batch.hint]);
@@ -204,7 +204,7 @@ function refuseAccountBilledCaps(options) {
204
204
  function haltBeforeTurn(session, observation) {
205
205
  if (session.signal?.aborted)
206
206
  return stops.harnessAborted;
207
- return (scheduledEndStop(session, observation) ??
207
+ return (clockEndStop(session, observation) ??
208
208
  (session.remaining() < 0 ? stops.timeLimit(session) : undefined));
209
209
  }
210
210
  /**
@@ -37,12 +37,21 @@ 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", "scheduled_end"];
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", "session_cap"];
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";
44
44
  type ActorProtocol = "json-rpc" | "json-stream" | "cua-loop" | "scripted-steps" | "terminal-exec";
45
45
  export type ActorTraceItemKind = "message" | "reasoning" | "tool_call" | "command" | "file_change" | "approval" | "screenshot" | "ui_action" | "plan" | "notice";
46
+ /**
47
+ * Where a participant's browser was at one observation: the URL's origin and its path, redacted
48
+ * (src/evidence/trace-page.ts). No query string, fragment or user info. A non-web URL keeps only
49
+ * its scheme, as `{ origin: "about:", path: "" }`.
50
+ */
51
+ export interface TracePage {
52
+ origin: string;
53
+ path: string;
54
+ }
46
55
  export interface ActorTraceItem {
47
56
  id: string;
48
57
  kind: ActorTraceItemKind;
@@ -74,6 +83,9 @@ export interface ActorTraceItem {
74
83
  x: number;
75
84
  y: number;
76
85
  };
86
+ /** On a computer-use `screenshot` item: the page that observation was on. Additive: absent when
87
+ * the observation read no URL, on other producers and in older bundles. */
88
+ page?: TracePage;
77
89
  }
78
90
  export interface ActorCapabilities {
79
91
  headless: boolean;
@@ -338,19 +350,24 @@ export interface ActorTrace {
338
350
  */
339
351
  taskFunnel?: TaskFunnel;
340
352
  /**
341
- * Additive + optional: the outcome the participant declared on its final turn, when its
342
- * provider's reply carries the field. Absent on free-text providers and on every older bundle.
343
- * The route reads this before it reads the closing paragraph; three regex patches in one month
344
- * each fixed a false refusal and each left the next shape unhandled.
353
+ * Additive + optional: the outcome the participant declared, in its provider's outcome field or
354
+ * in the fixed first line its prompt asks for. It comes from its last message when it ended the
355
+ * session itself, and from its accepted closing report at a scheduled end or a session cap.
356
+ * Absent when it declared none, after a stop condition or a dwell window, and on every older
357
+ * bundle. The route reads a declared `reached` or `blocked` before it reads the closing
358
+ * paragraph; three regex patches in one month each fixed a false refusal and each left the next
359
+ * shape unhandled. A declared `not_reached` leaves the paragraph to say whether a blocker
360
+ * stopped the participant.
345
361
  */
346
362
  declaredOutcome?: ParticipantDeclaredOutcome;
347
363
  /**
348
364
  * The read-only closing request: a closing report after a harness-owned stop (`stop_when`,
349
- * `dwell`, `scheduled_end`), or impressions only after the participant ended the session itself
350
- * (`participant_end`, never with `report`). Does not change task outcomes or permit actions.
365
+ * `dwell`, `scheduled_end`, `session_cap`), or impressions only after the participant ended the
366
+ * session itself (`participant_end`, never with `report`). Does not change task outcomes or
367
+ * permit actions.
351
368
  */
352
369
  debrief?: {
353
- trigger: "stop_when" | "dwell" | "scheduled_end" | "participant_end";
370
+ trigger: "stop_when" | "dwell" | "scheduled_end" | "session_cap" | "participant_end";
354
371
  status: "completed" | "skipped" | "failed";
355
372
  reason: string;
356
373
  /** Absent if no request was made; false means token accounting is incomplete. */
@@ -366,6 +383,12 @@ export interface ActorTrace {
366
383
  */
367
384
  impressions?: ParticipantImpressions;
368
385
  items: ActorTraceItem[];
386
+ /**
387
+ * Additive + optional: the page of the computer-use session's last observation, the closing
388
+ * task observation included. Absent when that observation read no URL, so a failed read never
389
+ * repeats an earlier page, and on other producers and older bundles.
390
+ */
391
+ finalPage?: TracePage;
369
392
  tokenUsage?: ActorTokenUsage;
370
393
  /** A stalled or adapter-reported ambiguous interaction may have additional unreported usage.
371
394
  * Known tokenUsage remains usable as a partial total. Absence is not proof of completeness. */
@@ -56,6 +56,9 @@ export const ACTOR_STOP_CAUSES = [
56
56
  // The participant's scheduled end (participants[].endAfterMs) came. A planned end: the session
57
57
  // is goal_satisfied, and the closing report follows.
58
58
  "scheduled_end",
59
+ // The participant was still working at its session cap (participants[].maxSessionMs). A planned
60
+ // end, as scheduled_end is: the session is goal_satisfied, and the closing report follows.
61
+ "session_cap",
59
62
  ];
60
63
  /**
61
64
  * The oldest Codex CLI release a recorded account profile may name. Readers accept every stable
@@ -11,6 +11,7 @@ const labels = {
11
11
  provider_refused_prompt: "provider refused the prompt under its usage policy",
12
12
  harness_aborted: "stopped by harness",
13
13
  scheduled_end: "scheduled end",
14
+ session_cap: "session cap",
14
15
  unspecified_limit: "limit reached",
15
16
  };
16
17
  /** Shared wording for consumers that already hold a validated, finite stop cause. */