humanish 0.125.0 → 0.127.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 (128) hide show
  1. package/CHANGELOG.md +29 -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 +10 -5
  9. package/dist/actors/computer-use/loop/ending.js +12 -7
  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/computer-use/openai-provider.js +1 -1
  18. package/dist/actors/contract.d.ts +31 -8
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/goal-source.d.ts +32 -2
  21. package/dist/actors/goal-source.js +41 -2
  22. package/dist/actors/stop-cause.js +1 -0
  23. package/dist/analysis/evidence-sources.js +2 -2
  24. package/dist/analysis/types.d.ts +2 -2
  25. package/dist/analysis/validation.js +2 -1
  26. package/dist/cli/commands/study.js +8 -0
  27. package/dist/evidence/redaction.d.ts +1 -0
  28. package/dist/evidence/redaction.js +1 -0
  29. package/dist/evidence/trace-page.d.ts +6 -0
  30. package/dist/evidence/trace-page.js +90 -0
  31. package/dist/observer/data.js +8 -5
  32. package/dist/observer/feed-cursors.d.ts +14 -0
  33. package/dist/observer/feed-cursors.js +61 -0
  34. package/dist/observer/participant-items.d.ts +7 -0
  35. package/dist/observer/pinned-files.d.ts +2 -1
  36. package/dist/observer/pinned-files.js +2 -2
  37. package/dist/observer/run-routes.d.ts +1 -1
  38. package/dist/observer/run-routes.js +38 -17
  39. package/dist/observer-app.html +7 -7
  40. package/dist/routes/computer-use/bundle.js +4 -0
  41. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  42. package/dist/routes/computer-use/diagnostics.js +3 -3
  43. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  44. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  45. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  46. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  47. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  48. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  49. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  50. package/dist/routes/computer-use/fanout-bundle.js +3 -0
  51. package/dist/routes/computer-use/fanout-records.js +5 -1
  52. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  54. package/dist/routes/computer-use/participant-execution.js +6 -2
  55. package/dist/routes/computer-use/participant-facts.js +4 -1
  56. package/dist/routes/computer-use/participant-model.js +16 -6
  57. package/dist/routes/computer-use/participant-runs.d.ts +10 -10
  58. package/dist/routes/computer-use/participant-runs.js +54 -84
  59. package/dist/routes/computer-use/plan.d.ts +2 -0
  60. package/dist/routes/computer-use/plan.js +65 -17
  61. package/dist/routes/computer-use/rerun-selection.js +8 -5
  62. package/dist/routes/computer-use/setup.d.ts +1 -0
  63. package/dist/routes/computer-use/setup.js +29 -1
  64. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  65. package/dist/routes/computer-use/single-bundle.js +11 -1
  66. package/dist/routes/computer-use/types.d.ts +18 -13
  67. package/dist/routes/computer-use/types.js +0 -3
  68. package/dist/routes/shared-world/bundle-records.js +4 -1
  69. package/dist/routes/shared-world/external-public.js +1 -1
  70. package/dist/routes/shared-world/participant-specs.js +3 -2
  71. package/dist/routes/shared-world/plan.d.ts +2 -0
  72. package/dist/routes/shared-world/plan.js +8 -3
  73. package/dist/routes/shared-world/provisioned.js +1 -1
  74. package/dist/run/bundle.d.ts +11 -1
  75. package/dist/run/contained-output.d.ts +7 -3
  76. package/dist/run/contained-output.js +11 -7
  77. package/dist/run/judge.d.ts +14 -4
  78. package/dist/run/judge.js +17 -12
  79. package/dist/run/live-items.d.ts +19 -0
  80. package/dist/run/live-items.js +28 -8
  81. package/dist/run/notes.js +1 -1
  82. package/dist/run/outcomes.d.ts +8 -2
  83. package/dist/run/outcomes.js +56 -14
  84. package/dist/run/stream-trace.d.ts +25 -4
  85. package/dist/run/stream-trace.js +55 -17
  86. package/dist/run/streams.d.ts +16 -0
  87. package/dist/study/arrivals.d.ts +105 -34
  88. package/dist/study/arrivals.js +179 -74
  89. package/dist/study/bundle-estimate.d.ts +12 -10
  90. package/dist/study/bundle-estimate.js +138 -72
  91. package/dist/study/desktop-files.d.ts +37 -0
  92. package/dist/study/desktop-files.js +174 -0
  93. package/dist/study/keys.d.ts +8 -0
  94. package/dist/study/keys.js +3 -0
  95. package/dist/study/migrate/v2.js +12 -3
  96. package/dist/study/parse/actors.d.ts +1 -1
  97. package/dist/study/parse/actors.js +28 -20
  98. package/dist/study/parse/clock.d.ts +18 -0
  99. package/dist/study/parse/clock.js +36 -0
  100. package/dist/study/plan-participants.d.ts +19 -10
  101. package/dist/study/plan-participants.js +13 -9
  102. package/dist/study/plan-types.d.ts +6 -1
  103. package/dist/study/plan.d.ts +2 -1
  104. package/dist/study/plan.js +28 -15
  105. package/dist/study/preflight.js +4 -0
  106. package/dist/study/types.d.ts +35 -5
  107. package/dist/study/validation.js +3 -0
  108. package/dist/study/warnings.d.ts +2 -2
  109. package/dist/study/warnings.js +7 -0
  110. package/dist/subject/local-tree-archive.d.ts +7 -0
  111. package/dist/subject/local-tree-archive.js +12 -0
  112. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  113. package/dist/substrates/e2b/desktop-browser.js +6 -2
  114. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  115. package/dist/substrates/e2b/desktop-clock.js +29 -0
  116. package/dist/substrates/e2b/lifetime.d.ts +22 -8
  117. package/dist/substrates/e2b/lifetime.js +54 -10
  118. package/dist/substrates/e2b/sdk.d.ts +6 -0
  119. package/dist/verify/pages.d.ts +7 -0
  120. package/dist/verify/pages.js +50 -0
  121. package/dist/verify/verify.js +10 -3
  122. package/docs/architecture/actor-contract.md +2 -0
  123. package/docs/architecture/external-public-shared-world.md +4 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/policy.md +7 -0
  126. package/docs/contracts/run-bundle.md +31 -1
  127. package/docs/contracts/schemas.md +93 -11
  128. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,35 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.127.0: Clock-end passes counted by what participants declared, a scaled wind-down, the concurrency cap in the plan (2026-10-13)
12
+
13
+ humanish 0.127.0 makes long scheduled studies report what happened. A participant that passes at
14
+ its scheduled end or session cap without declaring `REACHED THE GOAL.` is now counted as running
15
+ until that end (`participants.ranUntilEnd`), where 0.126.0 counted it as reaching the goal. The
16
+ default wind-down is a quarter of the window, at most 10 minutes, so a short window is no longer
17
+ told its end from its first turn. `HUMANISH_CUA_MAX_CONCURRENCY` now lowers the plan itself, on
18
+ shared-world studies too, and a participant whose scheduled end passes while it waits for a slot
19
+ is skipped before its desktop is created. The Observer keeps updating past 100,000 items for a
20
+ participant, and the bundle estimate is re-derived for run bundle v3.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.127.0)
23
+
24
+ ## 0.126.0: Per-participant time zones, session caps and desktop files, page paths, a lighter live Observer (2026-10-13)
25
+
26
+ humanish 0.126.0 adds per-participant settings for long, mixed studies and a lighter live Observer.
27
+ Each participant can have its own time zone (`participants[].clock`), which now also sets its
28
+ hosted browser, its desktop's panel clock and a `desktop-cli` terminal; an existing
29
+ `actor.clock.timeZone` sets them too. `participants[].maxSessionMs` lets a participant end its
30
+ session sooner and stops one still working at the cap with a closing report.
31
+ `participants[].files` puts files on a participant's desktop for upload dialogs, and each
32
+ computer-use turn records the page it was on (origin and path only). A closing report that opens
33
+ "REACHED THE GOAL." no longer fails its run over the page copy it paraphrases. The served Observer's
34
+ live feed sends only new items each poll: about 90 KB per poll through a synthetic 8-hour,
35
+ 24-participant run, where 0.125.0 sent up to 69.5 MB. Offsets and miscased zone names are now
36
+ refused, and so is a zone on the local browser runtime.
37
+
38
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.126.0)
39
+
11
40
  ## 0.125.0: Run bundle v3 with live items in append-only files, loadRunBundle, faster repeated verify (2026-10-12)
12
41
 
13
42
  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,17 @@ 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. A `>` blockquote line
27
+ * is skipped: it quotes something else, such as the page, and the blocker scan
28
+ * (routes/computer-use/self-report.ts) drops it too. Exported for tests.
27
29
  */
28
30
  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;
31
+ /**
32
+ * The participant's own word for how it ended: the turn's schema field first, then the closing
33
+ * line of `text`, its last message or its closing report's summary.
34
+ */
35
+ export declare function declaredOutcomeOf(turn: CuaTurn, text: string | undefined): ParticipantDeclaredOutcome | undefined;
31
36
  /**
32
37
  * The participant reported a natural endpoint. "not_reached" is a participant who stopped without
33
38
  * finishing: gave_up, which tallies as abandoned. "blocked" keeps goal_satisfied here (the actor
@@ -6,8 +6,10 @@ 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. A `>` blockquote line
11
+ * is skipped: it quotes something else, such as the page, and the blocker scan
12
+ * (routes/computer-use/self-report.ts) drops it too. Exported for tests.
11
13
  */
12
14
  export function declaredOutcomeFromClosingLine(message) {
13
15
  if (message === undefined)
@@ -15,10 +17,10 @@ export function declaredOutcomeFromClosingLine(message) {
15
17
  const first = message
16
18
  .split(/\r?\n/)
17
19
  .map((line) => line.trim())
18
- .find((line) => line.length > 0);
20
+ .find((line) => line.length > 0 && !line.startsWith(">"));
19
21
  if (first === undefined)
20
22
  return undefined;
21
- const normalized = first.replace(/^[*_#>\s-]+|[*_.!\s]+$/g, "").toLowerCase();
23
+ const normalized = first.replace(/^[*_#\s-]+|[*_.!\s]+$/g, "").toLowerCase();
22
24
  if (normalized === "reached the goal")
23
25
  return "reached";
24
26
  if (normalized === "did not reach the goal")
@@ -27,9 +29,12 @@ export function declaredOutcomeFromClosingLine(message) {
27
29
  return "blocked";
28
30
  return undefined;
29
31
  }
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);
32
+ /**
33
+ * The participant's own word for how it ended: the turn's schema field first, then the closing
34
+ * line of `text`, its last message or its closing report's summary.
35
+ */
36
+ export function declaredOutcomeOf(turn, text) {
37
+ return turn.outcome ?? declaredOutcomeFromClosingLine(text);
33
38
  }
34
39
  /**
35
40
  * 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
  /**
@@ -512,7 +512,7 @@ export function createOpenAiResponsesProvider(options) {
512
512
  // Both are offered in both modes. A threaded request reads the session the server holds; an
513
513
  // explicit_context one carries its newest screens whole and its oldest turns summarized past
514
514
  // the budget, which the trace's conversation record counts. A participant that worked a long
515
- // shift writes its closing account from what it carries, as a person writes one from memory.
515
+ // session writes its closing account from what it carries, as a person writes one from memory.
516
516
  debrief: (req, signal) => requestTurn(req, signal, "report"),
517
517
  requestImpressions: (req, signal) => requestTurn(req, signal, "impressions"),
518
518
  };