humanish 0.140.0 → 0.141.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 (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actors/codex/restricted-participant.js +1 -1
  3. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  4. package/dist/actors/computer-use/external-handoff.js +83 -0
  5. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  6. package/dist/actors/computer-use/loop/observation.js +17 -1
  7. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  8. package/dist/analysis/analysis-limits.d.ts +6 -0
  9. package/dist/analysis/analysis-limits.js +6 -0
  10. package/dist/analysis/analysis-lock.d.ts +8 -0
  11. package/dist/analysis/analysis-lock.js +129 -0
  12. package/dist/analysis/automatic-completion.js +7 -0
  13. package/dist/analysis/execute.js +1 -1
  14. package/dist/analysis/job.d.ts +1 -0
  15. package/dist/analysis/job.js +6 -2
  16. package/dist/analysis/recording-ended.d.ts +16 -0
  17. package/dist/analysis/recording-ended.js +29 -0
  18. package/dist/analysis/service.d.ts +1 -3
  19. package/dist/analysis/service.js +23 -50
  20. package/dist/analysis/source.d.ts +1 -1
  21. package/dist/browser-control/protocol.d.ts +8 -0
  22. package/dist/browser-control/protocol.js +21 -0
  23. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  24. package/dist/cli/commands/analysis-signals.js +28 -6
  25. package/dist/cli/commands/runs.js +8 -2
  26. package/dist/cli/commands/study-route-computer-use.js +2 -2
  27. package/dist/cli/commands/study-route-scripted.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study-route-terminal.js +2 -2
  30. package/dist/cli/findings.d.ts +23 -0
  31. package/dist/cli/findings.js +80 -4
  32. package/dist/cli/io.js +1 -0
  33. package/dist/evidence/browser-hygiene.d.ts +12 -1
  34. package/dist/evidence/browser-hygiene.js +45 -2
  35. package/dist/feedback/export-bundle.js +1 -1
  36. package/dist/guest/external-links.d.ts +14 -0
  37. package/dist/guest/external-links.js +48 -0
  38. package/dist/guest/runtime-desktop.js +5 -2
  39. package/dist/observer/data.d.ts +4 -0
  40. package/dist/observer/run-routes.js +12 -3
  41. package/dist/observer-app.html +2 -2
  42. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  43. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  44. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  45. package/dist/routes/computer-use/live-phase.js +1 -1
  46. package/dist/routes/computer-use/setup.js +2 -2
  47. package/dist/routes/shared-world/route.js +1 -1
  48. package/dist/run/display.d.ts +6 -1
  49. package/dist/run/display.js +11 -0
  50. package/dist/run/host-suspension.js +4 -1
  51. package/dist/run/preflight-receipts.d.ts +3 -12
  52. package/dist/run/preflight-receipts.js +7 -67
  53. package/dist/run/process-owner.d.ts +28 -0
  54. package/dist/run/process-owner.js +83 -0
  55. package/dist/run/projection.js +1 -1
  56. package/dist/run/reclaim.d.ts +6 -0
  57. package/dist/run/reclaim.js +32 -2
  58. package/dist/run/route-shell.js +1 -1
  59. package/dist/run/run-index.d.ts +3 -1
  60. package/dist/run/run-index.js +23 -11
  61. package/dist/run/run.d.ts +16 -5
  62. package/dist/run/run.js +25 -10
  63. package/dist/run/status.d.ts +57 -2
  64. package/dist/run/status.js +87 -1
  65. package/dist/run/stored-runs.d.ts +3 -0
  66. package/dist/run/stored-runs.js +9 -4
  67. package/dist/study/run-study-events.d.ts +7 -1
  68. package/dist/substrates/e2b/desktop-browser.js +3 -1
  69. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  70. package/dist/substrates/e2b/desktop-executor.js +3 -0
  71. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  72. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  73. package/dist/substrates/local/runtime-release.js +8 -8
  74. package/dist/tui-app.js +115 -115
  75. package/docs/architecture/observer.md +7 -3
  76. package/docs/contracts/schemas.md +24 -3
  77. package/docs/contracts/study-analysis.md +1 -1
  78. package/docs/product/automatic-analysis.md +29 -7
  79. package/package.json +1 -1
  80. package/skills/humanish/SKILL.md +108 -5
package/dist/run/run.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type RunBundle } from "./bundle.js";
3
3
  import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
4
4
  import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
5
5
  import { RunSecrets } from "./secrets.js";
6
+ import { type RunStatusHandle } from "./status.js";
6
7
  import { type HostClock, type HostSuspension } from "./host-suspension.js";
7
8
  import type { RunStudyProvenance } from "./study-provenance.js";
8
9
  interface StartRunOptions {
@@ -170,7 +171,7 @@ export declare class FinishedRun {
170
171
  readonly runId: string;
171
172
  /** The paths created by startRun and validated at the start of `finish`. */
172
173
  readonly paths: PreparedRunArtifactPaths;
173
- constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, interrupted: () => boolean, participantsRan: () => boolean);
174
+ constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, status: Pick<RunStatusHandle, "interrupted" | "beginAnalysis">, participantsRan: () => boolean);
174
175
  /**
175
176
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
176
177
  * when asked, since the signal can arrive after the bundle was published.
@@ -178,6 +179,12 @@ export declare class FinishedRun {
178
179
  get interrupted(): boolean;
179
180
  /** The route reported a participant's session started (Run.participantStarted). */
180
181
  get participantsRan(): boolean;
182
+ /**
183
+ * Automatic analysis of this run starts in this process: status.json records when, and its
184
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
185
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
186
+ */
187
+ beginAnalysis(): ReturnType<RunStatusHandle["beginAnalysis"]>;
181
188
  static isIssued(value: unknown): value is FinishedRun;
182
189
  /**
183
190
  * The run's ok and execution outcome as run.json records them: what the route passed to
@@ -195,12 +202,16 @@ export declare class FinishedRun {
195
202
  /**
196
203
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
197
204
  * so the scope and its run admit no further calls, even through references `fn` leaked into
198
- * timers or callbacks. It then waits for the calls it admitted and, unless the run finished,
199
- * finishes status.json with no outcome. The close cannot replace `fn`'s exception: admitted calls
200
- * are awaited settled and the status finish swallows its own write errors. `finished` is set only
205
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
206
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
207
+ * not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
208
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
201
209
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
202
210
  */
203
- export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>): Promise<{
211
+ export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?: {
212
+ /** The run exists: its directory and first status.json write landed. A throw is ignored. */
213
+ onStart?: (runId: string) => void;
214
+ }): Promise<{
204
215
  result: T;
205
216
  finished: FinishedRun | undefined;
206
217
  }>;
package/dist/run/run.js CHANGED
@@ -35,34 +35,42 @@ const issueKey = Symbol("FinishedRun");
35
35
  export class FinishedRun {
36
36
  #observer;
37
37
  #recordFailure;
38
- #interrupted;
39
38
  #outcome;
40
39
  runId;
41
40
  /** The paths created by startRun and validated at the start of `finish`. */
42
41
  paths;
43
42
  #participantsRan;
44
- constructor(key, runId, paths, observer, outcome, recordFailure, interrupted, participantsRan) {
43
+ #status;
44
+ constructor(key, runId, paths, observer, outcome, recordFailure, status, participantsRan) {
45
45
  if (key !== issueKey)
46
46
  throw new Error("Only Run.finish issues a FinishedRun.");
47
47
  this.runId = runId;
48
48
  this.paths = paths;
49
49
  this.#participantsRan = participantsRan;
50
+ this.#status = status;
50
51
  this.#observer = observer;
51
52
  this.#outcome = outcome;
52
53
  this.#recordFailure = recordFailure;
53
- this.#interrupted = interrupted;
54
54
  }
55
55
  /**
56
56
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
57
57
  * when asked, since the signal can arrive after the bundle was published.
58
58
  */
59
59
  get interrupted() {
60
- return this.#interrupted();
60
+ return this.#status.interrupted;
61
61
  }
62
62
  /** The route reported a participant's session started (Run.participantStarted). */
63
63
  get participantsRan() {
64
64
  return this.#participantsRan();
65
65
  }
66
+ /**
67
+ * Automatic analysis of this run starts in this process: status.json records when, and its
68
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
69
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
70
+ */
71
+ beginAnalysis() {
72
+ return this.#status.beginAnalysis();
73
+ }
66
74
  static isIssued(value) {
67
75
  return typeof value === "object" && value !== null && #observer in value;
68
76
  }
@@ -262,7 +270,7 @@ function runPublisher(args) {
262
270
  await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
263
271
  return current;
264
272
  }));
265
- return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, () => runStatus.interrupted, participantsRan);
273
+ return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, runStatus, participantsRan);
266
274
  };
267
275
  const interrupt = async (signal) => {
268
276
  interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
@@ -287,12 +295,13 @@ function runPublisher(args) {
287
295
  /**
288
296
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
289
297
  * so the scope and its run admit no further calls, even through references `fn` leaked into
290
- * timers or callbacks. It then waits for the calls it admitted and, unless the run finished,
291
- * finishes status.json with no outcome. The close cannot replace `fn`'s exception: admitted calls
292
- * are awaited settled and the status finish swallows its own write errors. `finished` is set only
298
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
299
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
300
+ * not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
301
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
293
302
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
294
303
  */
295
- export async function runScope(fn) {
304
+ export async function runScope(fn, hooks = {}) {
296
305
  let closed = false;
297
306
  let started = false;
298
307
  let status;
@@ -339,6 +348,12 @@ export async function runScope(fn) {
339
348
  },
340
349
  });
341
350
  await runStatus.started;
351
+ try {
352
+ hooks.onStart?.(runId);
353
+ }
354
+ catch {
355
+ // An announcement cannot stop the run it announces.
356
+ }
342
357
  const run = openRun(options, runId, created.paths, runStatus);
343
358
  interruptBundle = run.interrupt;
344
359
  return { ok: true, run: run.run };
@@ -414,7 +429,7 @@ export async function runScope(fn) {
414
429
  }
415
430
  finally {
416
431
  if (finished === undefined)
417
- await status?.finish();
432
+ await status?.abandon();
418
433
  unregister?.();
419
434
  }
420
435
  }
@@ -1,3 +1,4 @@
1
+ import type { AutomaticAnalysisOutcome } from "../analysis/job.js";
1
2
  import type { RunBundle } from "./bundle.js";
2
3
  import { type RunStudyProvenance } from "./study-provenance.js";
3
4
  import { type PreparedOutputRoot } from "./contained-output.js";
@@ -10,11 +11,26 @@ export declare const RUN_STATUS_FILE = "status.json";
10
11
  export declare const RUN_STATUS_TOUCH_MS = 5000;
11
12
  /**
12
13
  * A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
13
- * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
14
+ * died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
14
15
  * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
15
16
  */
16
17
  export declare const RUN_STATUS_STALE_MS: number;
17
18
  type RunStatusState = "running" | "finished" | "interrupted";
19
+ /**
20
+ * Why a run is `interrupted` without a signal. `no-publish`: its process left the run without
21
+ * publishing a bundle, as a route that throws or returns a refusal after the run started does.
22
+ * `reclaimed`: `humanish reclaim` found its heartbeat stale and stopped its sandboxes.
23
+ */
24
+ type RunInterruptReason = "no-publish" | "reclaimed";
25
+ /** How automatic analysis ended, as its job records it. */
26
+ type RunAnalysisEndState = AutomaticAnalysisOutcome["state"];
27
+ /** Automatic analysis in the run's own process, after the run ended. */
28
+ interface RunStatusAnalysis {
29
+ startedAt: string;
30
+ /** Absent while it runs, and on the record of a process that stopped during it. */
31
+ endedAt?: string;
32
+ state?: RunAnalysisEndState;
33
+ }
18
34
  /** The signals the CLI's run handler records when it stops a run. */
19
35
  export type RunInterruptSignal = "SIGINT" | "SIGTERM" | "SIGHUP";
20
36
  /** The outcome summary a finalized record carries. Derived from the bundle; never authoritative. */
@@ -67,7 +83,14 @@ export interface RunStatusRecord {
67
83
  completedAt?: string;
68
84
  /** The signal that stopped the run, on an `interrupted` record. */
69
85
  signal?: RunInterruptSignal;
86
+ /** Why an `interrupted` record has no signal. */
87
+ reason?: RunInterruptReason;
70
88
  outcome?: RunStatusOutcome;
89
+ /**
90
+ * Set on a `finished` record when automatic analysis starts in the same process. While it
91
+ * runs, the heartbeat keeps `updatedAt` fresh; `completedAt` stays when the participants ended.
92
+ */
93
+ analysis?: RunStatusAnalysis;
71
94
  }
72
95
  export interface RunStatusHandle {
73
96
  /** Resolves once the initial record has landed on disk, and never rejects: a failed write is
@@ -75,7 +98,8 @@ export interface RunStatusHandle {
75
98
  * it before a route can acquire a sandbox, so a run killed after its sandbox receipt lands
76
99
  * still has a record to classify. */
77
100
  readonly started: Promise<void>;
78
- /** Finalize: state `finished`, `completedAt`, and the derived outcome. Stops the cadence.
101
+ /** The run ended with a published bundle: state `finished`, `completedAt`, and the derived
102
+ * outcome. Stops the cadence.
79
103
  * Idempotent: a second call is a no-op, so a route with several exit paths is safe. */
80
104
  finish(outcome?: RunStatusOutcome): Promise<void>;
81
105
  /** After finish, replace the finished record's outcome with one read from a rewritten run.json,
@@ -88,6 +112,15 @@ export interface RunStatusHandle {
88
112
  interrupt(signal: RunInterruptSignal): Promise<boolean>;
89
113
  /** True from the moment `interrupt` accepts a signal, before its write lands. */
90
114
  readonly interrupted: boolean;
115
+ /** The run's scope closed without a publish: state `interrupted` with reason `no-publish`. A
116
+ * no-op after finish or an interrupt, so it never overwrites a record that says more. */
117
+ abandon(): Promise<void>;
118
+ /**
119
+ * After finish, automatic analysis starts: `analysis.startedAt` is written and the heartbeat
120
+ * runs again until the returned function records the end and its state. Before finish or after
121
+ * an interrupt it writes nothing, and neither does the returned function.
122
+ */
123
+ beginAnalysis(): Promise<(state: RunAnalysisEndState) => Promise<void>>;
91
124
  /** The host suspensions the cadence saw until now, or until it stopped. */
92
125
  suspensions(): readonly HostSuspension[];
93
126
  }
@@ -114,6 +147,28 @@ export type RunLiveness = "running" | "interrupted" | "finished";
114
147
  * `nowMs` is passed in rather than read, so a classification is reproducible.
115
148
  */
116
149
  export declare function classifyRunStatus(record: Pick<RunStatusRecord, "state" | "updatedAt">, nowMs: number, staleMs?: number): RunLiveness;
150
+ /** Automatic analysis as a status record shows it: running, ended, or stopped with its process. */
151
+ export type RunAnalysisPhase = {
152
+ state: "running" | "stopped";
153
+ startedAt: string;
154
+ } | {
155
+ state: "ended";
156
+ startedAt: string;
157
+ endedAt: string;
158
+ outcome?: RunAnalysisEndState;
159
+ };
160
+ /**
161
+ * The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
162
+ * stale: the process ended during it. Undefined when the record has none, or one this version
163
+ * cannot read.
164
+ */
165
+ export declare function runAnalysisPhase(record: Pick<RunStatusRecord, "updatedAt" | "analysis">, nowMs: number): RunAnalysisPhase | undefined;
166
+ /**
167
+ * A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
168
+ * owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
169
+ * are reused), so such a record stays unconfirmed.
170
+ */
171
+ export declare function runOwnerGone(record: Pick<RunStatusRecord, "state" | "pid" | "updatedAt">, nowMs: number, running?: (pid: number) => boolean): boolean;
117
172
  /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
118
173
  export declare function isRunStatusRecord(value: unknown): value is RunStatusRecord;
119
174
  export {};
@@ -20,7 +20,7 @@ export const RUN_STATUS_FILE = "status.json";
20
20
  export const RUN_STATUS_TOUCH_MS = 5_000;
21
21
  /**
22
22
  * A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
23
- * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
23
+ * died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
24
24
  * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
25
25
  */
26
26
  export const RUN_STATUS_STALE_MS = RUN_STATUS_TOUCH_MS * 3;
@@ -132,6 +132,48 @@ export function beginRunStatus(runPaths, options) {
132
132
  get interrupted() {
133
133
  return interrupted;
134
134
  },
135
+ async abandon() {
136
+ if (finished)
137
+ return;
138
+ finished = true;
139
+ heartbeat.stop();
140
+ const completedAt = iso();
141
+ await write({
142
+ ...base,
143
+ state: "interrupted",
144
+ updatedAt: completedAt,
145
+ completedAt,
146
+ reason: "no-publish",
147
+ });
148
+ },
149
+ async beginAnalysis() {
150
+ const ended = async () => { };
151
+ if (finishedRecord === undefined || interrupted)
152
+ return ended;
153
+ const startedAt = iso();
154
+ finishedRecord = { ...finishedRecord, updatedAt: startedAt, analysis: { startedAt } };
155
+ await write(finishedRecord);
156
+ const analysisHeartbeat = startHeartbeat(clock, RUN_STATUS_TOUCH_MS, () => {
157
+ if (finishedRecord === undefined)
158
+ return;
159
+ finishedRecord = { ...finishedRecord, updatedAt: iso() };
160
+ void write(finishedRecord);
161
+ });
162
+ let done = false;
163
+ return async (state) => {
164
+ if (done || finishedRecord?.analysis === undefined)
165
+ return;
166
+ done = true;
167
+ analysisHeartbeat.stop();
168
+ const endedAt = iso();
169
+ finishedRecord = {
170
+ ...finishedRecord,
171
+ updatedAt: endedAt,
172
+ analysis: { ...finishedRecord.analysis, endedAt, state },
173
+ };
174
+ await write(finishedRecord);
175
+ };
176
+ },
135
177
  suspensions: () => heartbeat.suspensions(),
136
178
  };
137
179
  return handle;
@@ -150,6 +192,50 @@ export function classifyRunStatus(record, nowMs, staleMs = RUN_STATUS_STALE_MS)
150
192
  return "interrupted";
151
193
  return nowMs - updated <= staleMs ? "running" : "interrupted";
152
194
  }
195
+ /**
196
+ * The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
197
+ * stale: the process ended during it. Undefined when the record has none, or one this version
198
+ * cannot read.
199
+ */
200
+ export function runAnalysisPhase(record, nowMs) {
201
+ const analysis = record.analysis;
202
+ if (analysis === null || typeof analysis !== "object")
203
+ return undefined;
204
+ const { startedAt, endedAt, state } = analysis;
205
+ if (typeof startedAt !== "string" || !Number.isFinite(Date.parse(startedAt)))
206
+ return undefined;
207
+ if (typeof endedAt === "string")
208
+ return {
209
+ state: "ended",
210
+ startedAt,
211
+ endedAt,
212
+ ...(typeof state === "string" ? { outcome: state } : {}),
213
+ };
214
+ const updated = Date.parse(record.updatedAt);
215
+ return Number.isFinite(updated) && nowMs - updated <= RUN_STATUS_STALE_MS
216
+ ? { state: "running", startedAt }
217
+ : { state: "stopped", startedAt };
218
+ }
219
+ /** Whether `pid` names a process on this host. EPERM means one exists that this user cannot signal. */
220
+ function processRunning(pid) {
221
+ try {
222
+ process.kill(pid, 0);
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ return error.code === "EPERM";
227
+ }
228
+ }
229
+ /**
230
+ * A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
231
+ * owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
232
+ * are reused), so such a record stays unconfirmed.
233
+ */
234
+ export function runOwnerGone(record, nowMs, running = processRunning) {
235
+ if (record.state !== "running" || classifyRunStatus(record, nowMs) === "running")
236
+ return false;
237
+ return record.pid !== process.pid && !running(record.pid);
238
+ }
153
239
  /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
154
240
  export function isRunStatusRecord(value) {
155
241
  if (value === null || typeof value !== "object")
@@ -1,5 +1,6 @@
1
1
  import { type ReviewSummary } from "./bundle.js";
2
2
  import { type RunDisplay } from "./display.js";
3
+ import { type RunAnalysisPhase } from "./status.js";
3
4
  import { type VerifyResult } from "../verify/verify.js";
4
5
  declare const RUNS_SCHEMA = "humanish.runs-result.v1";
5
6
  export interface RunsResult {
@@ -13,6 +14,8 @@ export interface RunsResult {
13
14
  path: string;
14
15
  /** How the run reads (runDisplay in src/run/display.ts); absent when it has no readable run.json. */
15
16
  display?: RunDisplay;
17
+ /** The automatic analysis its own process started after the run ended, from status.json. */
18
+ analysis?: RunAnalysisPhase;
16
19
  }>;
17
20
  latest: string | null;
18
21
  error?: {
@@ -6,7 +6,7 @@ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutpu
6
6
  import { RUN_BUNDLE_FILE } from "./bundle.js";
7
7
  import { isReviewSummary, isRunBundle, isRunOutcome } from "./bundle-shape.js";
8
8
  import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
9
- import { RUN_STATUS_FILE } from "./status.js";
9
+ import { isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "./status.js";
10
10
  import { readLatest, readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
11
11
  import { withCuaReviewProvenance } from "./outcomes.js";
12
12
  import { displayedTraces, loadStreamTraces } from "./stream-trace.js";
@@ -106,10 +106,15 @@ async function listedRunDisplay(runPaths, bundle) {
106
106
  const displayed = displayedBundle(bundle);
107
107
  if (displayed === undefined)
108
108
  return {};
109
- const status = displayed.outcome === undefined
110
- ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE))
109
+ const status = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
110
+ const analysis = isRunStatusRecord(status) && status.runId === displayed.runId
111
+ ? runAnalysisPhase(status, Date.now())
111
112
  : undefined;
112
- return { display: runDisplay(bundleDisplayFacts(displayed, status)) };
113
+ return {
114
+ // A bundle with an outcome decides how the run ended; status.json only adds its analysis.
115
+ display: runDisplay(bundleDisplayFacts(displayed, displayed.outcome === undefined ? status : undefined)),
116
+ ...(analysis === undefined ? {} : { analysis }),
117
+ };
113
118
  }
114
119
  /** The fields runDisplay reads from an unchecked run.json; none when it does not name a run. */
115
120
  function displayedBundle(bundle) {
@@ -19,7 +19,8 @@ export type SetupTarget = {
19
19
  readonly participant: ParticipantRef;
20
20
  };
21
21
  /**
22
- * What a run reports while it runs. `plan` comes from computer use only; the other routes run from
22
+ * What a run reports while it runs. `run-started` comes from every route that records a run, once
23
+ * its run id exists. `plan` comes from computer use only; the other routes run from
23
24
  * the study plan but do not emit it. `subject-phase` comes from computer use (participant target)
24
25
  * and shared world (subject target).
25
26
  */
@@ -40,6 +41,11 @@ export type StudyEvent = {
40
41
  at: string;
41
42
  ok?: boolean;
42
43
  durationMs?: number;
44
+ }
45
+ /** The run's directory and status.json exist: `runId` names it from here on. */
46
+ | {
47
+ type: "run-started";
48
+ runId: string;
43
49
  } | {
44
50
  type: "analysis-started";
45
51
  } | {
@@ -5,6 +5,7 @@ import { failureTail } from "../../evidence/redaction.js";
5
5
  import { isHttpUrl } from "../../study/parse/subject.js";
6
6
  import { runDetachedStep } from "../detached.js";
7
7
  import { shellQuote } from "../shell.js";
8
+ import { externalLinkHandlerSetup } from "./desktop-handoff.js";
8
9
  import { e2bShell } from "./shell.js";
9
10
  // Settle after opening the browser or the terminal, before the first screenshot: long enough for
10
11
  // a cold browser and page load to paint (2 s captured a blank desktop; the render needs 6-9 s).
@@ -210,7 +211,8 @@ extraChromiumFlags = [], environment) {
210
211
  `target_url=${shellQuote(targetUrl)}`,
211
212
  `browser_preference=${shellQuote(requestedBrowser)}`,
212
213
  "profile_dir=",
213
- `chrome_preferences_json=${shellQuote(chromiumEvidenceProfilePreferencesJson())}`,
214
+ `chrome_preferences_json=${shellQuote(chromiumEvidenceProfilePreferencesJson(targetUrl))}`,
215
+ externalLinkHandlerSetup(),
214
216
  "prepare_profile() {",
215
217
  ' profile_dir="$(mktemp -d /tmp/humanish-chrome-profile.XXXXXX)"',
216
218
  ' mkdir -p "$profile_dir/Default"',
@@ -1,4 +1,5 @@
1
1
  import type { CuaExecutor, CuaObservation } from "../../actors/computer-use/loop.js";
2
+ import type { ExternalHandoff } from "../../actors/computer-use/external-handoff.js";
2
3
  /**
3
4
  * The minimal slice of the @e2b/desktop Sandbox the executor depends on: a structural subset of
4
5
  * the SDK class (checked against 2.4.0), so a Sandbox satisfies it with no adapter. Methods are typed to return `Promise<void> | void` (and the
@@ -65,6 +66,12 @@ export interface E2BDesktopExecutorOptions {
65
66
  * never persists raw URL/title/text; it only uses them in memory to decide whether to stop.
66
67
  */
67
68
  observeBrowserState?: () => Promise<Pick<CuaObservation, "url" | "title" | "text" | "scrollY">>;
69
+ /**
70
+ * Optional read of the links the browser handed to another app since the last read
71
+ * (desktop-handoff.ts). It runs beside the browser probe and the screenshot; a read that fails
72
+ * leaves its links for the next one.
73
+ */
74
+ readExternalHandoffs?: () => Promise<readonly ExternalHandoff[]>;
68
75
  }
69
76
  /** The stage of a `type` action that failed. */
70
77
  export type CuaTypePhase = "desktop-write" | "text-tempfile" | "text-command";
@@ -151,10 +151,13 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
151
151
  // Probe browser state before the screenshot so a deterministic stopWhen match
152
152
  // and its persisted frame describe the same settled surface as closely as the
153
153
  // desktop substrate allows.
154
+ const handoffs = options.readExternalHandoffs?.().catch(() => []);
154
155
  const browserState = await options.observeBrowserState?.().catch(() => undefined);
155
156
  const raw = await desktop.screenshot();
156
157
  const screenshot = toBuffer(raw);
158
+ const externalHandoffs = (await handoffs) ?? [];
157
159
  return {
160
+ ...(externalHandoffs.length === 0 ? {} : { externalHandoffs }),
158
161
  screenshot,
159
162
  stateSignature: perceptualSignature(screenshot),
160
163
  ...(browserState?.url === undefined ? {} : { url: browserState.url }),
@@ -0,0 +1,22 @@
1
+ import { type ExternalHandoff } from "../../actors/computer-use/external-handoff.js";
2
+ import { type Shell } from "../shell.js";
3
+ /**
4
+ * One stand-in, installed in `bin`, appending to `log` (a shell word). `$0` names the command it
5
+ * stands in for. The local guest image ships the same script
6
+ * (runtime/browser-guest/control/root/opt/humanish/control/bin/xdg-open).
7
+ */
8
+ export declare function externalLinkStandIn(bin: string, log: string): string;
9
+ /**
10
+ * Shell lines for the browser launch script, run before the browser starts: they install the
11
+ * stand-ins and put them first on the search path for every command the script starts after them.
12
+ */
13
+ export declare function externalLinkHandlerSetup(dir?: string): string;
14
+ /**
15
+ * Returns a read of the links handed off since the previous read, oldest first and at most
16
+ * EXTERNAL_HANDOFF_LIMITS.perObservation at a time. Reads run one at a time, so a retried
17
+ * observation cannot return a link twice. A line still being written is left for the next read.
18
+ */
19
+ export declare function externalHandoffReader(shell: Pick<Shell, "run">, options: {
20
+ requestTimeoutMs: number;
21
+ dir?: string;
22
+ }): () => Promise<ExternalHandoff[]>;
@@ -0,0 +1,86 @@
1
+ // Links the participant's browser hands to another app (mailto:, tel:, sms:) on a hosted desktop.
2
+ //
3
+ // Chrome on Linux opens such a link by running `xdg-email` (mailto) or `xdg-open` (the rest) by
4
+ // name, unless an XDG desktop portal answers on the session bus; the stock desktop runs none.
5
+ // The stock desktop has no mail, phone or messaging app, so `xdg-open` ended in XFCE's "Choose
6
+ // Preferred Application" dialog, which participants reported as a fault of the app under test.
7
+ // The browser launch puts stand-ins for both commands first on the browser's search path. A stand-in
8
+ // appends the link to a log and exits; a web or file link goes on to the desktop's own command.
9
+ // The executor reads the log on each observation, and the participant loop records each link
10
+ // and tells the participant what happened. Chrome's own "Open xdg-open?" prompt for tel: and
11
+ // the other non-mailto schemes is answered by the profile preference in
12
+ // src/evidence/browser-hygiene.ts; Chrome never prompts for mailto.
13
+ import { EXTERNAL_HANDOFF_LIMITS, externalHandoffOf, } from "../../actors/computer-use/external-handoff.js";
14
+ import { shellQuote } from "../shell.js";
15
+ /** Where the stand-ins and their log live on the desktop. */
16
+ const EXTERNAL_LINK_DIR = "/tmp/humanish-external-links";
17
+ const LINK_LENGTH = 4096;
18
+ /**
19
+ * One stand-in, installed in `bin`, appending to `log` (a shell word). `$0` names the command it
20
+ * stands in for. The local guest image ships the same script
21
+ * (runtime/browser-guest/control/root/opt/humanish/control/bin/xdg-open).
22
+ */
23
+ export function externalLinkStandIn(bin, log) {
24
+ return [
25
+ "#!/bin/sh",
26
+ "# Installed by humanish for a study participant's browser: this desktop has no app for this",
27
+ "# link, so the link is written down for the study instead of opening a chooser dialog.",
28
+ `bin=${shellQuote(bin)}`,
29
+ "url=",
30
+ 'for arg in "$@"; do case "$arg" in -*) ;; *) url=$arg; break ;; esac; done',
31
+ 'case "$url" in',
32
+ " [Hh][Tt][Tt][Pp]:*|[Hh][Tt][Tt][Pp][Ss]:*|[Ff][Ii][Ll][Ee]:*) ;;",
33
+ " [A-Za-z]*:*)",
34
+ ` printf '%s\\n' "$(printf '%s' "$url" | tr -d '\\r\\n' | cut -c 1-${LINK_LENGTH})" >> ${log}`,
35
+ " exit 0 ;;",
36
+ "esac",
37
+ // Anything else goes to the desktop's own command, found on the search path without this
38
+ // directory.
39
+ "set -f",
40
+ "real=",
41
+ "IFS=:",
42
+ 'for entry in $PATH; do [ "$entry" = "$bin" ] || real=${real:+$real:}$entry; done',
43
+ 'PATH=$real exec "${0##*/}" "$@"',
44
+ "",
45
+ ].join("\n");
46
+ }
47
+ /**
48
+ * Shell lines for the browser launch script, run before the browser starts: they install the
49
+ * stand-ins and put them first on the search path for every command the script starts after them.
50
+ */
51
+ export function externalLinkHandlerSetup(dir = EXTERNAL_LINK_DIR) {
52
+ const bin = `${dir}/bin`;
53
+ return [
54
+ `mkdir -p ${shellQuote(bin)}`,
55
+ `touch ${shellQuote(`${dir}/links.log`)}`,
56
+ `printf '%s' ${shellQuote(externalLinkStandIn(bin, shellQuote(`${dir}/links.log`)))} > ${shellQuote(`${bin}/xdg-open`)}`,
57
+ `cp ${shellQuote(`${bin}/xdg-open`)} ${shellQuote(`${bin}/xdg-email`)}`,
58
+ `chmod 755 ${shellQuote(`${bin}/xdg-open`)} ${shellQuote(`${bin}/xdg-email`)}`,
59
+ `export PATH=${shellQuote(bin)}:"$PATH"`,
60
+ ].join("\n");
61
+ }
62
+ /**
63
+ * Returns a read of the links handed off since the previous read, oldest first and at most
64
+ * EXTERNAL_HANDOFF_LIMITS.perObservation at a time. Reads run one at a time, so a retried
65
+ * observation cannot return a link twice. A line still being written is left for the next read.
66
+ */
67
+ export function externalHandoffReader(shell, options) {
68
+ const log = `${options.dir ?? EXTERNAL_LINK_DIR}/links.log`;
69
+ let consumed = 0;
70
+ let previous = Promise.resolve();
71
+ const read = async () => {
72
+ const result = await shell.run(`tail -n +${consumed + 1} ${shellQuote(log)} 2>/dev/null | head -n ${EXTERNAL_HANDOFF_LIMITS.perObservation}`, { requestTimeoutMs: options.requestTimeoutMs, timeoutMs: 10_000 });
73
+ const lines = result.stdout.split("\n");
74
+ lines.pop();
75
+ consumed += lines.length;
76
+ return lines.flatMap((line) => {
77
+ const handoff = externalHandoffOf(line);
78
+ return handoff === undefined ? [] : [handoff];
79
+ });
80
+ };
81
+ return () => {
82
+ const next = previous.then(read, read);
83
+ previous = next.catch(() => undefined);
84
+ return next;
85
+ };
86
+ }
@@ -1,18 +1,18 @@
1
1
  /** Updated with the verified, public runtime artifact before release. */
2
2
  const LOCAL_RUNTIME_RELEASE = {
3
- url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.1/runtime-linux-amd64.tar.gz",
4
- sha256: "1de53db000f661e91ff6ea9a2eb8198b08b850621cf63d8ce7c1b046f5035faf",
5
- bytes: 597029770,
6
- image: "sha256:c805c310b7b086276efa74ea5735e8b559457c7edba85c5c9e3ac7934e8760f2",
3
+ url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.3/runtime-linux-amd64.tar.gz",
4
+ sha256: "6fc19792e9b8f414165a73470526cc414c98d0ad368210c5e95daf29469b8dbd",
5
+ bytes: 597049520,
6
+ image: "sha256:4672388737bd7d136910fca6f8b6d99e09fbe96c13e1b100491c05444e7d267a",
7
7
  };
8
8
  /** Architecture-specific artifacts; each retains matching sources and notices. */
9
9
  export const LOCAL_RUNTIME_RELEASES = {
10
10
  amd64: LOCAL_RUNTIME_RELEASE,
11
11
  arm64: {
12
- url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.2/runtime-linux-arm64.tar.gz",
13
- sha256: "ea7a42e12eae3b4d9140fff41e4e3512930202f9ccbb688497b146634d1cef10",
14
- bytes: 583555960,
15
- image: "sha256:e21c33b4745afdb3bac6a69c76606814348032ee661228c9bfdd9c7d06591a28",
12
+ url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.4/runtime-linux-arm64.tar.gz",
13
+ sha256: "d590de49a86eabe1cd09d7556aa75b2ebd62bc2c88fe37122e2af315c478c901",
14
+ bytes: 583551803,
15
+ image: "sha256:4606122520504f7f587f52b872165e6ab900be0ba3eca933b2f6dd3b9dcde723",
16
16
  },
17
17
  };
18
18
  /** Media assets are separate so ordinary browser studies keep the smaller download. */