humanish 0.130.0 → 0.131.1

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 (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/actors/codex/restricted-participant.d.ts +2 -2
  3. package/dist/actors/codex/restricted-participant.js +3 -3
  4. package/dist/actors/computer-use/loop/actions.d.ts +17 -2
  5. package/dist/actors/computer-use/loop/actions.js +24 -5
  6. package/dist/actors/computer-use/loop/backstop.d.ts +9 -1
  7. package/dist/actors/computer-use/loop/backstop.js +4 -3
  8. package/dist/actors/computer-use/loop/clock.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/clock.js +5 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +21 -2
  11. package/dist/actors/computer-use/loop/observation.js +50 -8
  12. package/dist/actors/computer-use/loop/trace.js +5 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +30 -19
  14. package/dist/actors/computer-use/loop/watch.d.ts +42 -0
  15. package/dist/actors/computer-use/loop/watch.js +260 -0
  16. package/dist/actors/computer-use/loop.js +18 -4
  17. package/dist/actors/computer-use/wait.d.ts +36 -18
  18. package/dist/actors/computer-use/wait.js +52 -23
  19. package/dist/actors/contract.d.ts +34 -0
  20. package/dist/actors/local-agent/cli.js +5 -0
  21. package/dist/evidence/frame-change.d.ts +39 -0
  22. package/dist/evidence/frame-change.js +293 -0
  23. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  24. package/dist/routes/computer-use/e2b-desktop/start.d.ts +9 -0
  25. package/dist/routes/computer-use/e2b-desktop/start.js +17 -2
  26. package/dist/study/plan-participants.d.ts +2 -2
  27. package/dist/study/types.d.ts +11 -7
  28. package/dist/study/warnings.js +1 -1
  29. package/dist/substrates/e2b/desktop-screen.d.ts +10 -0
  30. package/dist/substrates/e2b/desktop-screen.js +25 -0
  31. package/docs/architecture/actor-contract.md +8 -0
  32. package/docs/architecture/browser-control.md +11 -9
  33. package/docs/contracts/schemas.md +48 -8
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,33 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.131.1: Hosted desktops keep their screens on while participants wait (2026-10-13)
12
+
13
+ humanish 0.131.1 keeps a hosted desktop's screen on while a participant waits without input.
14
+ About 1 in 8 stock E2B desktops started with a screen saver that turned the screen black after 5
15
+ minutes, and a participant then spent its turns keeping the screen awake: one live hour cost
16
+ $17.70 where its pair cost $2.03. humanish now turns the screen saver off once the participant's
17
+ browser or terminal has opened. The refusal of an out-of-range `actor.maxWaitMs` also now says the
18
+ field caps a turn of only waits.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.131.1)
21
+
22
+ ## 0.131.0: Participants that wait watch the screen and wake when it changes (2026-10-13)
23
+
24
+ humanish 0.131.0 watches the screen while a computer-use or shared-world participant waits. A
25
+ turn of only waits sends no wait to the desktop: humanish looks every `idleWaitMs` and asks the
26
+ model for the next turn when a change holds still, speech is heard, a condition matches, the
27
+ participant's end arrives or the wait's time passes, and idle turns in a row grow toward
28
+ `actor.maxWaitMs`. A clock, a spinner or a "2 minutes ago" label ends no wait. In a live hour on a
29
+ support queue, new rows reached the participant a median of 7 seconds after they appeared, against
30
+ 30 seconds on 0.129.0, and an OpenAI participant made 50 requests where 0.129.0 made 43. Codex
31
+ and Claude Code waits last the length they ask for, up to 10 minutes. This is a minor release with
32
+ breaking changes: `idleWaitMs` now sets the time between looks, `actor.maxWaitMs` defaults to 10
33
+ minutes for idle turns and accepts up to an hour, and a turn of only waits sends no `wait` action
34
+ to a library caller's executor. The migration table says how to keep the old pacing.
35
+
36
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.131.0)
37
+
11
38
  ## 0.130.0: Analysis reads traces up to 32 MiB one at a time and names what it refused; the bundle estimate counts the mission (2026-10-13)
12
39
 
13
40
  humanish 0.130.0 lets analysis read long runs that 0.129.0 refused. Analysis reads each
@@ -18,8 +18,8 @@ export interface RestrictedParticipantOptions {
18
18
  /** Admit speech actions and heard-speaker evidence for a speech-capable desktop only. */
19
19
  speechEnabled?: boolean;
20
20
  /**
21
- * The study's actor.maxWaitMs, which the loop applies and the tool description states. Default
22
- * defaultMaxWaitMs(speechEnabled), the same default the loop applies.
21
+ * The study's actor.maxWaitMs, which the loop applies and the tool description states with the
22
+ * defaults the loop applies without it (resolveWaitSettings).
23
23
  */
24
24
  maxWaitMs?: number;
25
25
  }
@@ -3,7 +3,7 @@ import { defaultCodexCliVersion, untestedOperatorReleaseWarning } from "./codex-
3
3
  import { IMPRESSIONS_ASK, } from "../computer-use/loop.js";
4
4
  import { ComputerUseProviderError, isComputerUseProviderError, } from "../computer-use/provider-error.js";
5
5
  import { validateBrowserControlPng, validateHeardSpeech } from "../../browser-control/protocol.js";
6
- import { defaultMaxWaitMs } from "../computer-use/wait.js";
6
+ import { resolveWaitSettings } from "../computer-use/wait.js";
7
7
  import { createRestrictedCodexSession, } from "./restricted-session.js";
8
8
  import { PARTICIPANT_PROFILE, PARTICIPANT_LIMITS as L, PARTICIPANT_FINAL_SCHEMA, participantToolSchema, parseParticipantTool, parseParticipantFinal, } from "./restricted-participant-policy.js";
9
9
  import { truncatedFrameWarning, unknownNotificationsWarning } from "./restricted-notifications.js";
@@ -41,7 +41,7 @@ function deferred() {
41
41
  return { promise, resolve, reject };
42
42
  }
43
43
  // prose-check: model prompt (Codex reads this tool description, not a person)
44
- const toolDescription = (speechEnabled, maxWaitMs) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A wait action lasts at most ${maxWaitMs} ms; a longer wait is shortened to ${maxWaitMs} ms, so wait longer with another call.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
44
+ const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits returns when the screen changes and holds still${speechEnabled ? ", when speech is heard" : ""}, or after its waits' ms, together at most ${watchMaxMs} ms. While no change on the screen holds still, each further such call in a row waits twice as long as the one before, up to ${watchMaxMs} ms, and contextHint says how long it waited and why it returned. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
45
45
  const participantInstructions = (prompt, speechEnabled) => `${prompt}\n\nYou are the study participant throughout this conversation, including its closing account. Use only the supplied screenshots and humanish_ui tool to interact. The tool returns a JSON string: parse it, inspect acknowledgments${speechEnabled ? " and heardSpeech captured from the actual participant speaker sink" : ""}, and display imageUrl with Code Mode image(). Do not print the image data URL as text. Keep your persona and earlier observations throughout the session.${speechEnabled ? " A speak action plays into the participant microphone; use it only after the visible UI shows that you joined the call and the microphone is unmuted." : ""} Speak publicly about your experience, never reveal private reasoning. Completed inputs do not prove application outcomes; verify on the next screenshot. When the task ends, return only the required final JSON with outcome, summary, frictionReports and impressions. Report observed confusion and recovered mistakes as well as blockers. ${IMPRESSIONS_ASK} Do not invent observations.`;
46
46
  /** Turns and failures from the native run, handed in order to the next provider request. */
47
47
  class ParticipantEvents {
@@ -192,7 +192,7 @@ function participantSession(options, speechEnabled, effort, call) {
192
192
  reasoningEffort: effort,
193
193
  tool: {
194
194
  name: "humanish_ui",
195
- description: toolDescription(speechEnabled, options.maxWaitMs ?? defaultMaxWaitMs(speechEnabled)),
195
+ description: toolDescription(speechEnabled, resolveWaitSettings({ maxWaitMs: options.maxWaitMs, speechEnabled })),
196
196
  inputSchema: participantToolSchema(speechEnabled),
197
197
  call,
198
198
  },
@@ -1,3 +1,4 @@
1
+ import type { ActorWatch } from "../../contract.js";
1
2
  import type { LoopSession } from "./session.js";
2
3
  import type { CuaAction, CuaTurnRequest } from "./types.js";
3
4
  /** A public-safe one-line action label. Never includes raw typed text. */
@@ -21,9 +22,23 @@ export interface ActionBatch {
21
22
  */
22
23
  readonly hint: string | undefined;
23
24
  }
25
+ /**
26
+ * The waits of a turn the loop watched in place of sending them to the desktop (watch.ts): the
27
+ * part of the watch each wait covered, in order, and the `watch` field of the wait during which
28
+ * the watch ended.
29
+ */
30
+ export interface WatchedWaits {
31
+ readonly ms: readonly number[];
32
+ readonly endedOn: number;
33
+ readonly watch: ActorWatch;
34
+ /** The session ended during the watch, so the turn's screenshot actions are not sent. */
35
+ readonly sessionEnded: boolean;
36
+ }
24
37
  /**
25
38
  * Dispatch one turn's actions in order. A declared pre-dispatch rejection stops the batch and
26
39
  * leaves the desktop usable; a failed desktop command skips its action; any other failure ends
27
- * the session.
40
+ * the session. The waits of a watched turn (`watched`) already ran: each is recorded with the part
41
+ * of the watch it covered and nothing is sent for it. When that watch ended the session, the
42
+ * turn's screenshots are acknowledged as not dispatched and not recorded.
28
43
  */
29
- export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[]): Promise<ActionBatch>;
44
+ export declare function runActionBatch(session: LoopSession, actions: readonly CuaAction[], watched?: WatchedWaits): Promise<ActionBatch>;
@@ -119,19 +119,34 @@ function recordShortenedWait(session, title, requestedMs) {
119
119
  /**
120
120
  * Dispatch one turn's actions in order. A declared pre-dispatch rejection stops the batch and
121
121
  * leaves the desktop usable; a failed desktop command skips its action; any other failure ends
122
- * the session.
122
+ * the session. The waits of a watched turn (`watched`) already ran: each is recorded with the part
123
+ * of the watch it covered and nothing is sent for it. When that watch ended the session, the
124
+ * turn's screenshots are acknowledged as not dispatched and not recorded.
123
125
  */
124
- export async function runActionBatch(session, actions) {
126
+ export async function runActionBatch(session, actions, watched) {
125
127
  const execution = { actions: [] };
126
128
  const { actionHistory, trace } = session;
127
129
  const notes = [];
128
130
  const hint = () => (notes.length === 0 ? undefined : notes.join(" "));
129
131
  const idleTurn = isIdleTurn(actions);
132
+ let waits = 0;
130
133
  for (const [index, requested] of actions.entries()) {
131
134
  if (session.signal?.aborted)
132
135
  throw new CuaAbortError();
133
- const wait = requested.kind === "wait" ? planWait(session.waits, requested.ms, idleTurn) : undefined;
134
- const action = wait === undefined ? requested : { kind: "wait", ms: wait.ms };
136
+ if (watched?.sessionEnded === true && requested.kind !== "wait") {
137
+ execution.actions.push({ index, status: "not_dispatched" });
138
+ continue;
139
+ }
140
+ const waitIndex = requested.kind === "wait" ? waits++ : undefined;
141
+ const watchedMs = waitIndex === undefined ? undefined : watched?.ms[waitIndex];
142
+ const wait = requested.kind === "wait" && watchedMs === undefined
143
+ ? planWait(session.waits, requested.ms, idleTurn)
144
+ : undefined;
145
+ const action = watchedMs !== undefined
146
+ ? { kind: "wait", ms: watchedMs }
147
+ : wait === undefined
148
+ ? requested
149
+ : { kind: "wait", ms: wait.ms };
135
150
  const title = describeCuaAction(action);
136
151
  if (wait?.shortenedFromMs !== undefined)
137
152
  notes.push(recordShortenedWait(session, title, wait.shortenedFromMs));
@@ -147,7 +162,10 @@ export async function runActionBatch(session, actions) {
147
162
  session.phase = `executing ${title}`;
148
163
  let status;
149
164
  try {
150
- status = await dispatchAction(session, action, title, wait?.steps);
165
+ status =
166
+ watchedMs === undefined
167
+ ? await dispatchAction(session, action, title, wait?.steps)
168
+ : "completed";
151
169
  }
152
170
  catch (error) {
153
171
  const declared = isComputerUseExecutorError(error)
@@ -190,6 +208,7 @@ export async function runActionBatch(session, actions) {
190
208
  ...(action.kind === "click" || action.kind === "double_click"
191
209
  ? { coord: { x: action.x, y: action.y } }
192
210
  : {}),
211
+ ...(watched !== undefined && waitIndex === watched.endedOn ? { watch: watched.watch } : {}),
193
212
  }));
194
213
  }
195
214
  return { execution, hint: hint() };
@@ -12,6 +12,12 @@ import type { CuaAction, CuaObservation } from "./types.js";
12
12
  * mutates the input. (See docs/architecture/state-driven-executor.md.)
13
13
  */
14
14
  export declare function stableProgressKey(appState: Record<string, unknown>): string;
15
+ /**
16
+ * The friction progress key: a stable projection of appState when present, else stateSignature.
17
+ * A state executor with a constant signature still registers progress when its appState changes,
18
+ * and a vision executor behaves exactly as before.
19
+ */
20
+ export declare function progressKeyOf(observation: CuaObservation): string;
15
21
  export interface Backstop {
16
22
  readonly progressKey: string;
17
23
  /** The last few turns' action fingerprints, in memory only, for the corroboration rule below. */
@@ -37,11 +43,13 @@ export interface BackstopStep {
37
43
  /**
38
44
  * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
39
45
  * 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.
46
+ * toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
47
+ * says the turn's watch woke on a change it saw, which the progress key can miss.
41
48
  */
42
49
  export declare function advanceBackstop(previous: Backstop, turn: {
43
50
  actions: readonly CuaAction[];
44
51
  observation: CuaObservation;
45
52
  heardNewSpeech: boolean;
46
53
  waitingIsTheTask?: boolean;
54
+ screenChanged?: boolean;
47
55
  }, limits: BackstopLimits): BackstopStep;
@@ -103,7 +103,7 @@ export function stableProgressKey(appState) {
103
103
  * A state executor with a constant signature still registers progress when its appState changes,
104
104
  * and a vision executor behaves exactly as before.
105
105
  */
106
- function progressKeyOf(observation) {
106
+ export function progressKeyOf(observation) {
107
107
  const base = observation.appState !== undefined
108
108
  ? stableProgressKey(observation.appState)
109
109
  : observation.stateSignature;
@@ -125,7 +125,8 @@ export function startBackstop(observation) {
125
125
  /**
126
126
  * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
127
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.
128
+ * toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
129
+ * says the turn's watch woke on a change it saw, which the progress key can miss.
129
130
  */
130
131
  export function advanceBackstop(previous, turn, limits) {
131
132
  const idle = isIdleTurn(turn.actions);
@@ -157,7 +158,7 @@ export function advanceBackstop(previous, turn, limits) {
157
158
  // behavioral signal already (the agent took nothing but screenshots and waits), so it keeps
158
159
  // reading the frame on its own: a repeated screenshot is exactly what an idle streak is, and
159
160
  // feeding repetition into it would grant an extra forgiveness step for being idle.
160
- const observedProgress = frameChanged || turn.heardNewSpeech;
161
+ const observedProgress = frameChanged || turn.heardNewSpeech || turn.screenChanged === true;
161
162
  const progressed = observedProgress || !repeatingRecentAction;
162
163
  // A screenshot/wait turn while the UI visibly changes may be patience through loading or a
163
164
  // transition, so grant a bounded recovery window. Do not grant infinite immunity: animated
@@ -14,8 +14,8 @@ export declare function clockHint(clock: ParticipantClock | undefined, now: () =
14
14
  */
15
15
  export declare function heldToScheduledEnd(session: LoopSession): boolean;
16
16
  /**
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.
17
+ * The stop at the participant's end, or undefined before `nowMs`, by default the session's clock,
18
+ * reaches it: its scheduled end or its session cap, which is the stop cause. The session passes as
19
+ * a planned end and the participant is asked for its closing report on the last observation.
20
20
  */
21
- export declare function clockEndStop(session: LoopSession, observation: CuaObservation): Stop | undefined;
21
+ export declare function clockEndStop(session: LoopSession, observation: CuaObservation, nowMs?: number): Stop | undefined;
@@ -37,13 +37,13 @@ export function heldToScheduledEnd(session) {
37
37
  return end !== undefined && end.kind !== "session_cap" && session.now() < end.atMs;
38
38
  }
39
39
  /**
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.
40
+ * The stop at the participant's end, or undefined before `nowMs`, by default the session's clock,
41
+ * reaches it: its scheduled end or its session cap, which is the stop cause. The session passes as
42
+ * a planned end and the participant is asked for its closing report on the last observation.
43
43
  */
44
- export function clockEndStop(session, observation) {
44
+ export function clockEndStop(session, observation, nowMs) {
45
45
  const end = session.settings.clock?.end;
46
- if (end === undefined || session.now() < end.atMs)
46
+ if (end === undefined || (nowMs ?? session.now()) < end.atMs)
47
47
  return undefined;
48
48
  const at = endTime(session.settings.clock, end.atMs);
49
49
  const kind = end.kind ?? "scheduled_end";
@@ -1,6 +1,14 @@
1
1
  import { type Stop } from "./ending.js";
2
2
  import type { LoopSession } from "./session.js";
3
3
  import type { CuaObservation } from "./types.js";
4
+ /** What one look during a watch found (watch.ts). */
5
+ export interface Look {
6
+ readonly observation: CuaObservation;
7
+ /** The look brought speech the participant had not heard. */
8
+ readonly speech: boolean;
9
+ /** A stopWhen rule, a pending dwell trigger or a newly completed task matched the look. */
10
+ readonly condition: boolean;
11
+ }
4
12
  /** What a checkpoint found: a stop, or the observation the next turn reacts to. */
5
13
  export type Checkpoint = {
6
14
  readonly stop: Stop;
@@ -18,15 +26,26 @@ export declare class DesktopObserver {
18
26
  constructor(session: LoopSession);
19
27
  /**
20
28
  * Observe, persist the frame, advance the task funnel, run a due dwell window, and check the
21
- * harness stop conditions. Turn 0 is the initial observation, before any model turn.
29
+ * harness stop conditions. Turn 0 is the initial observation, before any model turn. `looked` is
30
+ * the look that ended a watch: it stands in for the observation, so the frame that woke the
31
+ * participant is the one it sees, and its URL and app state were already handed on at the look.
22
32
  */
23
- checkpoint(turnNumber: number): Promise<Checkpoint>;
33
+ checkpoint(turnNumber: number, looked?: CuaObservation): Promise<Checkpoint>;
24
34
  /**
25
35
  * Hand an observation the next turn may react to to the runtime hooks, and note whether it
26
36
  * carried app state. Runtime-only: the participant's live location.href goes back to the
27
37
  * orchestrator and is never persisted.
28
38
  */
29
39
  private handToHooks;
40
+ private handUrlOn;
41
+ private handFrameToHook;
42
+ /**
43
+ * One look at the screen while the participant waits. Speech it brings is kept for the next
44
+ * request, its URL goes to the URL tap (the shared-world lobby latch) and a task it completes is
45
+ * recorded. Detects, without acting on, the conditions the checkpoint acts on; the watch that
46
+ * asked hands a look that ends it to the checkpoint. Nothing is persisted.
47
+ */
48
+ look(turnNumber: number): Promise<Look>;
30
49
  /**
31
50
  * Every observation that carried app state counts, including dwell frames and the final task
32
51
  * observation, which no turn receives: they feed the task funnel, and the trace notes must say
@@ -28,9 +28,11 @@ export class DesktopObserver {
28
28
  }
29
29
  /**
30
30
  * Observe, persist the frame, advance the task funnel, run a due dwell window, and check the
31
- * harness stop conditions. Turn 0 is the initial observation, before any model turn.
31
+ * harness stop conditions. Turn 0 is the initial observation, before any model turn. `looked` is
32
+ * the look that ended a watch: it stands in for the observation, so the frame that woke the
33
+ * participant is the one it sees, and its URL and app state were already handed on at the look.
32
34
  */
33
- async checkpoint(turnNumber) {
35
+ async checkpoint(turnNumber, looked) {
34
36
  const { session } = this;
35
37
  const { stopWhen } = session.settings;
36
38
  const opening = turnNumber === 0;
@@ -41,7 +43,9 @@ export class DesktopObserver {
41
43
  throw new CuaAbortError();
42
44
  session.phase = `observing UI state after turn ${turnNumber}`;
43
45
  }
44
- let observation = this.handToHooks(this.withHeardSpeech(await this.observeBounded(opening ? "initial" : `after turn ${turnNumber}`)));
46
+ let observation = looked === undefined
47
+ ? this.handToHooks(this.withHeardSpeech(await this.observeBounded(opening ? "initial" : `after turn ${turnNumber}`)))
48
+ : this.handFrameToHook(looked);
45
49
  // Fail closed before a vision provider is asked to reason over a missing frame.
46
50
  const frameless = missingFrame(session.provider, observation);
47
51
  if (frameless !== undefined)
@@ -78,13 +82,48 @@ export class DesktopObserver {
78
82
  * orchestrator and is never persisted.
79
83
  */
80
84
  handToHooks(observation) {
81
- const { onObservedUrl, onScreenshot } = this.session.settings;
85
+ this.handUrlOn(observation);
86
+ return this.handFrameToHook(observation);
87
+ }
88
+ handUrlOn(observation) {
89
+ const { onObservedUrl } = this.session.settings;
82
90
  onObservedUrl?.(observation.url);
91
+ this.noteAppState(observation);
92
+ }
93
+ // The frame tap reads the lobby code with a model call outside the study budget, so it gets only
94
+ // frames a turn receives, never every look of a watch.
95
+ handFrameToHook(observation) {
96
+ const { onScreenshot } = this.session.settings;
83
97
  if (observation.screenshot !== undefined)
84
98
  onScreenshot?.(observation.screenshot);
85
- this.noteAppState(observation);
86
99
  return observation;
87
100
  }
101
+ /**
102
+ * One look at the screen while the participant waits. Speech it brings is kept for the next
103
+ * request, its URL goes to the URL tap (the shared-world lobby latch) and a task it completes is
104
+ * recorded. Detects, without acting on, the conditions the checkpoint acts on; the watch that
105
+ * asked hands a look that ends it to the checkpoint. Nothing is persisted.
106
+ */
107
+ async look(turnNumber) {
108
+ const { session } = this;
109
+ if (session.signal?.aborted)
110
+ throw new CuaAbortError();
111
+ session.phase = `looking at the screen during turn ${turnNumber}`;
112
+ const heardBefore = this.pendingHeardSpeech.length;
113
+ const observation = this.withHeardSpeech(await this.observeBounded(`look during turn ${turnNumber}`));
114
+ this.handUrlOn(observation);
115
+ const evidence = stopObservationOf(observation);
116
+ const { stopWhen, dwell } = session.settings;
117
+ const completedTask = this.observeTasks(observation, turnNumber);
118
+ const dwellDue = dwell?.when !== undefined &&
119
+ !this.dwellDone &&
120
+ evaluateStopWhen(dwell.when, evidence) !== undefined;
121
+ return {
122
+ observation,
123
+ speech: this.pendingHeardSpeech.length > heardBefore,
124
+ condition: completedTask || dwellDue || evaluateStopWhen(stopWhen, evidence) !== undefined,
125
+ };
126
+ }
88
127
  /**
89
128
  * Every observation that carried app state counts, including dwell frames and the final task
90
129
  * observation, which no turn receives: they feed the task funnel, and the trace notes must say
@@ -205,16 +244,19 @@ export class DesktopObserver {
205
244
  session.trace.bump("screenshots");
206
245
  }
207
246
  // Evaluated before the stopWhen check each turn so a final task whose criterion coincides with
208
- // the stop condition still lands in the funnel of the very turn that ends the session.
247
+ // the stop condition still lands in the funnel of the very turn that ends the session. Returns
248
+ // whether a task completed.
209
249
  observeTasks(observation, turnNumber) {
210
250
  const { taskTracker } = this.session;
211
251
  if (taskTracker === undefined)
212
- return;
213
- for (const completion of taskTracker.observe(stopObservationOf(observation), turnNumber)) {
252
+ return false;
253
+ const completions = taskTracker.observe(stopObservationOf(observation), turnNumber);
254
+ for (const completion of completions) {
214
255
  // The id is researcher-authored config and the kinds are rule-type names; the matched values
215
256
  // (a URL, page text) never appear here: the same discipline as the stopWhen notice.
216
257
  this.session.trace.record("notice", () => notice("matched", `task completed: ${this.session.redactNarration(completion.id)}`, this.session.redactNarration(`turn ${turnNumber}; matched rule ${completion.matchedRuleIndex} (${completion.matchedKinds.join("+")})`)));
217
258
  }
259
+ return completions.length > 0;
218
260
  }
219
261
  // The declared observation window. The harness holds, looks, and takes nothing back to
220
262
  // the model: no action, no turn, no tokens. It runs once, cut to whatever session budget is
@@ -129,7 +129,11 @@ export function loopResult(session, stop, debrief) {
129
129
  : { maxOutputTokens: provider.modelSettings.maxOutputTokens }),
130
130
  },
131
131
  }),
132
- waitSettings: { ...session.waits },
132
+ waitSettings: {
133
+ maxWaitMs: session.waits.maxWaitMs,
134
+ idleWaitMs: session.waits.idleWaitMs,
135
+ settleWaitMs: session.waits.settleWaitMs,
136
+ },
133
137
  counts,
134
138
  items: recorder.items,
135
139
  ...(session.finalPage === undefined ? {} : { finalPage: session.finalPage }),
@@ -297,8 +297,8 @@ export interface CuaLoopOptions {
297
297
  /** Injected clock (ms). Lets tests drive deadlines deterministically. */
298
298
  now: () => number;
299
299
  /**
300
- * Cancellation. Checked before each turn, each action and each dwell frame, and raced against
301
- * every provider and desktop call.
300
+ * Cancellation. Checked before each turn, each action, each dwell frame and each look of a
301
+ * watched turn, and raced against every provider and desktop call and each pause of a watch.
302
302
  */
303
303
  signal?: AbortSignal;
304
304
  /** Idle streak (no material action) that trips the backstop. Default 24. */
@@ -306,19 +306,25 @@ export interface CuaLoopOptions {
306
306
  /** No-progress streak that trips the backstop. Default 20. */
307
307
  noProgressSteps?: number;
308
308
  /**
309
- * The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. A longer wait is
310
- * shortened to it, the trace records a `wait shortened` notice, and the participant is told on its
311
- * next request. A wait longer than one desktop call carries is sent in steps. A whole number from
312
- * 1000 to 600000; any other value throws a RangeError before the session starts. Default
313
- * defaultMaxWaitMs: 120000, or 30000 when the executor is speechEnabled.
309
+ * The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. In a turn with another
310
+ * action a longer wait is shortened to it, the trace records a `wait shortened` notice, and the
311
+ * participant is told on its next request; a wait longer than one desktop call carries is sent in
312
+ * steps. A turn that only waits or takes screenshots is watched instead (loop/watch.ts): it first
313
+ * lasts its waits' lengths, the named ones together up to this or, when it is not set, 600000,
314
+ * and each further such turn in a row that ends with no change lasts twice as long, up to that
315
+ * or the turn's own length. A whole number from 1000 to 3600000; any other value throws a
316
+ * RangeError before the session starts. Default 120000, or 30000 when the executor is speechEnabled, for one wait sent to the
317
+ * desktop, and 600000 for a watch.
314
318
  */
315
319
  maxWaitMs?: number;
316
320
  /**
317
321
  * How long a `wait` that names no duration lasts, in ms, when its turn only waits or takes
318
- * screenshots: the study's actor.idleWaitMs. After an action in the same turn such a wait lasts
319
- * 500 ms. A whole number from 1000 to 600000 and no longer than an explicit maxWaitMs; any other
320
- * value throws a RangeError before the session starts. Default 5000. The session never applies
321
- * more than its maxWaitMs, and the trace records the applied lengths as `waitSettings`.
322
+ * screenshots: the study's actor.idleWaitMs. It is also how often the loop looks at the screen
323
+ * while such a turn is watched, at most every 30000 ms when the executor is speechEnabled. After
324
+ * an action in the same turn such a wait lasts 500 ms. A whole number from 1000 to 600000 and no
325
+ * longer than an explicit maxWaitMs; any other value throws a RangeError before the session
326
+ * starts. Default 5000. The session never applies more than its maxWaitMs, and the trace records
327
+ * the applied lengths as `waitSettings`.
322
328
  */
323
329
  idleWaitMs?: number;
324
330
  /**
@@ -347,9 +353,10 @@ export interface CuaLoopOptions {
347
353
  /** Persist a screenshot (raw or redacted per redactScreenshots), returning the trace ref path. */
348
354
  writeScreenshot?: (name: string, bytes: Buffer) => Promise<string>;
349
355
  /**
350
- * Deterministic harness-owned success guards. Evaluated after the initial observation and after
351
- * every post-action observation, before another model turn is requested. This keeps a participant from
352
- * wandering after the product already reached an app-visible endpoint.
356
+ * Deterministic harness-owned success guards. Evaluated after the initial observation, after
357
+ * every post-action observation and at each look while a turn is watched, before another model
358
+ * turn is requested. This keeps a participant from wandering after the product already reached an
359
+ * app-visible endpoint.
353
360
  */
354
361
  stopWhen?: StopWhen;
355
362
  /**
@@ -359,7 +366,10 @@ export interface CuaLoopOptions {
359
366
  * most once per session and never past the session budget.
360
367
  */
361
368
  dwell?: DwellWindow;
362
- /** Injected pause for the dwell window's cadence; tests advance their clock through it. */
369
+ /**
370
+ * Injected pause for the dwell window's cadence and the looks of a watched turn; tests advance
371
+ * their clock through it.
372
+ */
363
373
  sleep?: (ms: number) => Promise<void>;
364
374
  /**
365
375
  * What each turn tells the participant about the time (loop/clock.ts). With `timeZone`, a time zone
@@ -419,7 +429,7 @@ export interface CuaLoopOptions {
419
429
  * Trace snapshot for a watcher: the redacted items recorded so far, with the running
420
430
  * usage so a run can be priced in flight. Called after each checkpoint's screenshot (the initial
421
431
  * observation and every acted turn, so a flush never shows an action without the frame before
422
- * it), after each dwell frame, and after a closing request. The array is a fresh copy; its items
432
+ * it), after each dwell frame, when a watched turn starts watching, and after a closing request. The array is a fresh copy; its items
423
433
  * are the objects the final trace persists. Not awaited, so a slow flush cannot stall a turn.
424
434
  * Default: no-op.
425
435
  */
@@ -433,8 +443,8 @@ export interface CuaLoopOptions {
433
443
  export interface LoopTaps {
434
444
  /**
435
445
  * Runtime-only URL tap: called with `observation.url` for each observation a turn may receive
436
- * (the initial one, each post-action one and the one after a dwell window). The loop never
437
- * persists the raw URL. Default: no-op.
446
+ * (the initial one, each post-action one and the one after a dwell window) and each look while a
447
+ * turn is watched. The loop never persists the raw URL. Default: no-op.
438
448
  *
439
449
  * Built-in caller: the external-public shared-world handoff (src/routes/shared-world/handoff.ts)
440
450
  * latches the host participant's lobby URL and checks that each follower reached the same lobby.
@@ -452,7 +462,8 @@ export interface LoopTaps {
452
462
  onMessage?: (text: string) => void;
453
463
  /**
454
464
  * Runtime-only frame tap: called with each raw frame a turn may receive and each dwell-window
455
- * frame, before any redaction. The buffer stays in memory; screenshot persistence is separate and
465
+ * frame, before any redaction. Of a watched turn's looks, only the one that ended the watch is a
466
+ * frame a turn receives. The buffer stays in memory; screenshot persistence is separate and
456
467
  * follows redactScreenshots. Not awaited. Default: no-op.
457
468
  *
458
469
  * Built-in caller: the external-public handoff reads the lobby code off each participant's own
@@ -0,0 +1,42 @@
1
+ import type { ActorWatch } from "../../contract.js";
2
+ import { type WatchedWaits } from "./actions.js";
3
+ import { type Stop } from "./ending.js";
4
+ import type { DesktopObserver } from "./observation.js";
5
+ import type { LoopSession } from "./session.js";
6
+ import type { CuaAction, CuaObservation } from "./types.js";
7
+ /** A watch that ran for one idle turn: how it ended, and what the loop does next. */
8
+ export interface Watched {
9
+ readonly wake: ActorWatch["wake"];
10
+ /**
11
+ * The look that ended the watch, which the checkpoint takes in place of a fresh observe. Absent
12
+ * when no look ended it: a stalled look, or the participant's end.
13
+ */
14
+ readonly observation?: CuaObservation;
15
+ /** The next request's note: how long the participant waited and why it woke. */
16
+ readonly hint: string | undefined;
17
+ /**
18
+ * Set when the session ends here: the study budget or the session's time ran out, or the
19
+ * participant's end came.
20
+ */
21
+ readonly stop?: Stop;
22
+ /** Each wait's part of the watch, for the trace. */
23
+ readonly waits: WatchedWaits;
24
+ }
25
+ /**
26
+ * The watches of one participant's idle turns. Between turns it keeps the run of idle turns the
27
+ * doubling counts: a watch that ended at its limit or on a small change makes the next one in the
28
+ * run longer, and a turn with another action ends the run.
29
+ */
30
+ export declare class IdleWatcher {
31
+ private readonly session;
32
+ private readonly observer;
33
+ /** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
34
+ private grownFromMs;
35
+ constructor(session: LoopSession, observer: DesktopObserver);
36
+ /**
37
+ * Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
38
+ * another action, which ends the run, or with no wait time (screenshots only, or waits of 0 ms),
39
+ * which leaves the run as it is. `reference` is the observation the participant chose to wait on.
40
+ */
41
+ watch(turnNumber: number, reference: CuaObservation, actions: readonly CuaAction[]): Promise<Watched | undefined>;
42
+ }