humanish 0.112.0 → 0.113.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 (90) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +11 -0
  3. package/TELEMETRY.md +21 -0
  4. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  5. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  6. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  7. package/dist/actors/codex/restricted-participant.js +2 -1
  8. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  9. package/dist/actors/computer-use/loop/debrief.js +57 -20
  10. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  11. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  12. package/dist/actors/computer-use/loop/impressions.js +40 -0
  13. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  14. package/dist/actors/computer-use/loop/session.js +2 -0
  15. package/dist/actors/computer-use/loop/trace.js +2 -0
  16. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  17. package/dist/actors/computer-use/loop.d.ts +2 -1
  18. package/dist/actors/computer-use/loop.js +15 -3
  19. package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
  20. package/dist/actors/computer-use/openai-conversation.js +53 -24
  21. package/dist/actors/computer-use/openai-provider.js +18 -12
  22. package/dist/actors/contract.d.ts +43 -3
  23. package/dist/actors/contract.js +13 -0
  24. package/dist/analysis/evidence-sources.d.ts +3 -1
  25. package/dist/analysis/evidence-sources.js +10 -1
  26. package/dist/analysis/evidence.js +2 -2
  27. package/dist/analysis/execute.d.ts +1 -1
  28. package/dist/analysis/execute.js +3 -1
  29. package/dist/cli/commands/runs.js +33 -10
  30. package/dist/cli/commands/setup.js +95 -6
  31. package/dist/cli/commands/study-format.js +7 -4
  32. package/dist/cli/commands/study.js +1 -1
  33. package/dist/cli/commands/tui.js +1 -1
  34. package/dist/cli/doctor.d.ts +2 -0
  35. package/dist/cli/doctor.js +18 -0
  36. package/dist/cli/io.d.ts +2 -0
  37. package/dist/cli/program.js +35 -1
  38. package/dist/cli/secret-prompt.d.ts +5 -1
  39. package/dist/cli/secret-prompt.js +6 -2
  40. package/dist/cli/update-check-worker.d.ts +1 -0
  41. package/dist/cli/update-check-worker.js +5 -0
  42. package/dist/cli/update-check.d.ts +46 -0
  43. package/dist/cli/update-check.js +203 -0
  44. package/dist/feedback/draft.js +18 -0
  45. package/dist/keys/key-resolution.js +2 -0
  46. package/dist/keys/key-status.d.ts +37 -0
  47. package/dist/keys/key-status.js +68 -0
  48. package/dist/observer-app.html +4 -4
  49. package/dist/routes/computer-use/local-vm.js +9 -2
  50. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  51. package/dist/routes/computer-use/participant-execution.js +9 -1
  52. package/dist/routes/computer-use/rerun-selection.js +23 -21
  53. package/dist/routes/computer-use/result.js +3 -2
  54. package/dist/routes/computer-use/self-report.js +7 -1
  55. package/dist/routes/computer-use/setup.js +14 -0
  56. package/dist/routes/computer-use/types.d.ts +4 -1
  57. package/dist/routes/shared-world/bundle-records.js +8 -7
  58. package/dist/routes/shared-world/bundle.js +1 -1
  59. package/dist/run/detail.js +4 -4
  60. package/dist/run/participant-caption.d.ts +2 -1
  61. package/dist/run/participant-caption.js +11 -1
  62. package/dist/run/participant-records.d.ts +3 -1
  63. package/dist/run/participant-records.js +6 -0
  64. package/dist/run-study.js +17 -1
  65. package/dist/study/discover.js +6 -1
  66. package/dist/study/doctor.d.ts +2 -0
  67. package/dist/study/doctor.js +45 -7
  68. package/dist/study/init-templates.js +10 -5
  69. package/dist/study/plan.js +3 -2
  70. package/dist/study/preflight-result.d.ts +1 -0
  71. package/dist/study/preflight-result.js +1 -1
  72. package/dist/study/preflight.d.ts +2 -0
  73. package/dist/study/preflight.js +54 -14
  74. package/dist/study/warnings.d.ts +5 -0
  75. package/dist/study/warnings.js +79 -2
  76. package/dist/substrates/local/capacity.d.ts +75 -0
  77. package/dist/substrates/local/capacity.js +143 -0
  78. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  79. package/dist/substrates/local/firecracker-desktop.js +34 -3
  80. package/dist/substrates/local/runtime-host.d.ts +16 -3
  81. package/dist/substrates/local/runtime-host.js +49 -4
  82. package/dist/substrates/local/runtime.d.ts +10 -0
  83. package/dist/substrates/local/runtime.js +44 -5
  84. package/dist/tui-app.js +58 -58
  85. package/docs/architecture/actor-contract.md +20 -6
  86. package/docs/contracts/feedback.md +8 -1
  87. package/docs/contracts/schemas.md +57 -34
  88. package/docs/contracts/study-analysis.md +15 -0
  89. package/package.json +1 -1
  90. package/skills/humanish/SKILL.md +21 -10
@@ -3,8 +3,9 @@ import path from "node:path";
3
3
  import { inboxRecipientFor, } from "./participant-desktop.js";
4
4
  import { createLocalFirecrackerDesktop, } from "../../substrates/local/firecracker-desktop.js";
5
5
  import { localBrowserDefaults, localBrowserUnsupportedReason, } from "../../substrates/local/runtime-config.js";
6
- import { prepareLocalRuntime } from "../../substrates/local/runtime.js";
7
- import { dockerCommandLine } from "../../substrates/local/runtime-host.js";
6
+ import { localRuntimeCapacity, prepareLocalRuntime } from "../../substrates/local/runtime.js";
7
+ import { dockerCommandLine, usesLima } from "../../substrates/local/runtime-host.js";
8
+ import { outOfMemoryMessage } from "../../substrates/local/capacity.js";
8
9
  import { checkRestrictedCodexAnalysisReadiness } from "../../analysis/restricted-codex.js";
9
10
  import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-participant.js";
10
11
  import { withCloseReport } from "./participant-model.js";
@@ -110,6 +111,11 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
110
111
  warnings.push("Desktop video/audio recording could not be retained. Screenshots and participant evidence remain available.");
111
112
  }
112
113
  }
114
+ // Asked before close, which removes the container; the answer comes from Docker's events.
115
+ if ((await session.killedForMemory?.()) === true) {
116
+ evidence.failureCode = "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY";
117
+ evidence.desktopFailure = outOfMemoryMessage(usesLima() ? "lima-vm" : "linux-host");
118
+ }
113
119
  try {
114
120
  evidence.released = (await session.close()).status === "released";
115
121
  }
@@ -180,6 +186,7 @@ export function prepareLocalVmRun(options) {
180
186
  options: account ? { ...runOptions, createProvider: accountProvider(state) } : runOptions,
181
187
  localVm: {
182
188
  desktop: (run, warnings, artifactRoot) => createLocalParticipantDesktop(context, run, warnings, artifactRoot),
189
+ capacity: () => localRuntimeCapacity(),
183
190
  analysisRefusal: () => state.cleanupUnconfirmed ? "AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED" : undefined,
184
191
  ...(options.signal === undefined ? {} : { signal: options.signal }),
185
192
  },
@@ -16,6 +16,12 @@ export type ParticipantDesktopEvidence = {
16
16
  /** The substrate confirmed the desktop was released (an E2B kill or a local VM shutdown). The participant
17
17
  * outcome records it as `killed`. */
18
18
  released: boolean;
19
+ /**
20
+ * Why the desktop itself ended the session, in words a person reads, when the substrate knows:
21
+ * a local desktop Docker killed for memory. It replaces the session's error for a participant
22
+ * who did not pass.
23
+ */
24
+ desktopFailure?: string;
19
25
  } & Pick<ParticipantRunOutcome, "sandboxId" | "sandboxRelease" | "desktopDurationMs" | "desktopResources" | "streamUrlPresent" | "subjectCommit" | "desktopBrowser" | "desktopGeometry" | "stateStepRecords" | "phaseRecords" | "failureCode" | "commsArtifactPath" | "recording">;
20
26
  /**
21
27
  * The desktop one participant runs on, owned from preparation through final evidence collection.
@@ -107,6 +107,15 @@ export async function runCuaParticipant(spec, deps) {
107
107
  });
108
108
  }
109
109
  }
110
+ const { released, desktopFailure, ...desktopEvidence } = desktop.snapshot();
111
+ // A desktop that died under the participant explains the failure better than the transport
112
+ // error the session saw. A session that passed keeps its result.
113
+ if (desktopFailure !== undefined) {
114
+ if (session?.status === "passed")
115
+ warnings.push(desktopFailure);
116
+ else
117
+ sessionError = desktopFailure;
118
+ }
110
119
  if (session)
111
120
  await recordParticipantTrace(spec, deps, session, warnings);
112
121
  const { noEngagement, selfReportedBlocker, reportedFriction } = judgeParticipantSession(session, deps, warnings);
@@ -116,7 +125,6 @@ export async function runCuaParticipant(spec, deps) {
116
125
  providerCleanupError !== undefined ||
117
126
  providerPolicyError !== undefined ||
118
127
  session?.completionReason === "harness_error";
119
- const { released, ...desktopEvidence } = desktop.snapshot();
120
128
  return {
121
129
  spec,
122
130
  ...(session ? { session } : {}),
@@ -4,42 +4,44 @@ import {} from "../../run/bundle.js";
4
4
  import { loadRunBundle } from "../../run/locate.js";
5
5
  import {} from "../../run/streams.js";
6
6
  import { plural } from "../../run/text.js";
7
+ import { access } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { cli } from "../../cli/invocation.js";
7
10
  export async function resolveCuaRerunSelection(args) {
8
- const source = await loadRunBundle(args.cwd, args.sourceRunId);
11
+ const { studyId, sourceRunId } = args;
12
+ // Every refusal ends with the command that runs the whole study, which is what a user who
13
+ // reached for a rerun too early wants.
14
+ const instead = `To run every participant of ${studyId} instead, leave out --rerun-failed-from: ${cli(`watch ${studyId}`)} or ${cli(`run ${studyId}`)}.`;
15
+ const refuse = (what) => ({ ok: false, message: `${what} ${instead}` });
16
+ const source = await loadRunBundle(args.cwd, sourceRunId);
9
17
  if (!source) {
10
- return { ok: false, message: `source run not found or invalid: ${args.sourceRunId}` };
18
+ const noRuns = sourceRunId === "latest" &&
19
+ !(await access(path.join(args.cwd, ".humanish", "runs", "latest.json")).then(() => true, () => false));
20
+ return refuse(noRuns
21
+ ? "--rerun-failed-from latest found no run: this project has no runs yet, and a rerun repeats the failed participants of an earlier live run."
22
+ : `--rerun-failed-from ${sourceRunId} names no run humanish can read in this project. ${cli("runs")} lists its runs.`);
11
23
  }
12
24
  const bundle = source.bundle;
25
+ const run = `Run ${bundle.runId}${sourceRunId === bundle.runId ? "" : ` (the ${sourceRunId} run)`}`;
26
+ const notFanout = `${run} is not a live computer-use run with more than one participant, so it has no participants to rerun.`;
13
27
  if (bundle.mode !== "live") {
14
- return {
15
- ok: false,
16
- message: `source run ${bundle.runId} is ${bundle.mode}; rerun selection only applies to live CUA fan-out evidence.`,
17
- };
28
+ return refuse(`${run} is a dry run: no participant ran in it, so none can be rerun. A rerun repeats the failed participants of a live run with more than one participant.`);
18
29
  }
19
30
  const fanoutEvent = bundle.events.some((event) => event.type === "cua-lab.fanout.plan");
20
- if (!fanoutEvent || bundle.streams.length < 2) {
21
- return { ok: false, message: `source run ${bundle.runId} is not a CUA fan-out run.` };
22
- }
31
+ if (!fanoutEvent || bundle.streams.length < 2)
32
+ return refuse(notFanout);
23
33
  const prior = bundle.streams
24
34
  .map(snapshotPriorParticipant)
25
35
  .filter((entry) => entry !== null);
26
36
  const priorById = new Map(prior.map((entry) => [entry.participantId, entry]));
27
- if (priorById.size < 2) {
28
- return {
29
- ok: false,
30
- message: `source run ${bundle.runId} does not expose multiple participant ids.`,
31
- };
32
- }
37
+ if (priorById.size < 2)
38
+ return refuse(notFanout);
33
39
  const explicitIds = uniqueIds(args.participantIds ?? []);
34
40
  const selectedIds = explicitIds.length > 0
35
41
  ? explicitIds
36
42
  : prior.filter((entry) => entry.rerunnable).map((entry) => entry.participantId);
37
- if (selectedIds.length === 0) {
38
- return {
39
- ok: false,
40
- message: `source run ${bundle.runId} has no participant to rerun: none failed, was blocked, timed out or ended without engaging.`,
41
- };
42
- }
43
+ if (selectedIds.length === 0)
44
+ return refuse(`${run} has no participant to rerun: none failed, was blocked, timed out or ended without engaging.`);
43
45
  const missingPrior = selectedIds.filter((id) => !priorById.has(id));
44
46
  if (missingPrior.length > 0) {
45
47
  return {
@@ -98,8 +98,9 @@ function cuaStudyResult(args) {
98
98
  };
99
99
  }
100
100
  const failing = (outcomes ?? []).find((outcome) => !participantOk(outcome));
101
- const geometryFailure = (outcomes ?? []).find((outcome) => outcome.failureCode === "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY");
102
- const code = geometryFailure?.failureCode ?? "HUMANISH_COMPUTER_USE_FAILED";
101
+ // A participant whose desktop failed for a known cause gives the run that cause's code.
102
+ const codedFailure = (outcomes ?? []).find((outcome) => outcome.failureCode !== undefined);
103
+ const code = codedFailure?.failureCode ?? "HUMANISH_COMPUTER_USE_FAILED";
103
104
  return {
104
105
  code,
105
106
  message: observer.ok
@@ -200,8 +200,14 @@ export function resolveSelfReportedFriction(session) {
200
200
  // reached the goal and described what was hard on the way has reported friction.
201
201
  if (session.trace.declaredOutcome === "blocked")
202
202
  return session.reason;
203
+ // Impressions are opinions about the product, not reports of what went wrong in the session.
204
+ const impressions = new Set(session.trace.impressions?.status === "collected"
205
+ ? session.trace.impressions.items.map((item) => item.messageId)
206
+ : []);
203
207
  const messages = session.trace.items
204
- .filter((item) => item.kind === "message" && item.id !== session.trace.debrief?.messageId)
208
+ .filter((item) => item.kind === "message" &&
209
+ item.id !== session.trace.debrief?.messageId &&
210
+ !impressions.has(item.id))
205
211
  .map((item) => item.text?.trim() ?? "")
206
212
  .filter((text) => text.length > 0);
207
213
  // A custom session may keep its closing report only in reason even when earlier messages exist.
@@ -22,6 +22,7 @@ import {} from "./bundle.js";
22
22
  import { packRunLocalTree } from "./local-tree-pack.js";
23
23
  import { projectParticipantSubjects, subjectProvenanceArg } from "./subject-projection.js";
24
24
  import { participantSubjectEnv, } from "./types.js";
25
+ import { capacityShortfall } from "../../substrates/local/capacity.js";
25
26
  import { studyPersonaIds } from "../../study/persona-resolve.js";
26
27
  import { refusedResult } from "../../run/study-result.js";
27
28
  /** The physical project, bound before any caller hook runs. */
@@ -145,6 +146,11 @@ export async function admitCuaRun(plan, input, config) {
145
146
  });
146
147
  if (rejection)
147
148
  return refuse(rejection.code, rejection.message, descriptor.id);
149
+ const shortfall = await localCapacityShortfall(input.localVm, participantPlan.concurrency);
150
+ if (shortfall !== undefined && "refusal" in shortfall)
151
+ return refuse("HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED", shortfall.refusal, descriptor.id);
152
+ if (shortfall !== undefined)
153
+ process.stderr.write(`warning: ${shortfall.warning}\n`);
148
154
  }
149
155
  // Pack the working tree once per run, on the host, before any sandbox or provider call: every
150
156
  // fan-out participant below uploads this same archive, so one archiveSha256 describes every
@@ -192,6 +198,14 @@ export async function admitCuaRun(plan, input, config) {
192
198
  },
193
199
  };
194
200
  }
201
+ /**
202
+ * Whether a local study's desktops at once fit the local runtime. Read before any desktop: a
203
+ * desktop past the VM's memory is killed mid-session, after its participant has spent.
204
+ */
205
+ async function localCapacityShortfall(localVm, desktops) {
206
+ const capacity = await localVm?.capacity?.();
207
+ return capacity === undefined ? undefined : capacityShortfall(capacity, desktops);
208
+ }
195
209
  /** Starts the run and builds what the participants and the finish read: the deps and the bundle base. */
196
210
  export async function startCuaRun(plan, input, admitted, scope) {
197
211
  const { cwd, descriptor, participantRuns, participantPlan, publicRepo } = admitted;
@@ -1,4 +1,5 @@
1
1
  import type { Verdict } from "../../run/judge.js";
2
+ import type { LocalCapacity } from "../../substrates/local/capacity.js";
2
3
  import type { RunDesktopRecording } from "../../evidence/desktop-recording-types.js";
3
4
  import type { CuaExecutor, CuaLiveMetadata, CuaLoopResult, CuaProvider } from "../../actors/computer-use/loop.js";
4
5
  import type { CommsReceivingRun } from "../../comms/receiving.js";
@@ -38,6 +39,8 @@ export declare const MIN_DERIVED_SESSION_TIMEOUT_MS: number;
38
39
  */
39
40
  export interface LocalVmInput {
40
41
  readonly desktop: ParticipantDesktopFactory;
42
+ /** How many desktops the local runtime holds, or undefined when it cannot be read. */
43
+ readonly capacity?: () => Promise<LocalCapacity | undefined>;
41
44
  /** The skip reason automatic analysis records instead of running, or undefined to run it. */
42
45
  readonly analysisRefusal: () => AutomaticAnalysisRefusal | undefined;
43
46
  readonly signal?: AbortSignal;
@@ -182,7 +185,7 @@ export interface CuaParticipantSummary {
182
185
  concurrency: number;
183
186
  waves: number;
184
187
  }
185
- export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
188
+ export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
186
189
  /** Subject provenance projection: what the actor actually drove. */
187
190
  export interface CuaSubjectProjection {
188
191
  source: "app-url" | "clone" | "local-tree";
@@ -81,16 +81,17 @@ function sharedWorldStream(ctx, spec, index, view) {
81
81
  const { args } = ctx;
82
82
  const { name, session, screenshots, lastScreenshot, desktopGeometry, screenshotMode } = view;
83
83
  const judged = view.judgment.judgedStatus;
84
+ const caption = participantCaption({
85
+ id: spec.planned.id,
86
+ personaId: spec.persona.id,
87
+ device: spec.planned.device,
88
+ });
84
89
  return participantStream(spec, {
85
90
  ...(spec.evidenceAssignment === undefined
86
91
  ? {}
87
92
  : { assignment: participantAssignment(spec.evidenceAssignment) }),
88
93
  kind: "browser",
89
- label: participantCaption({
90
- id: spec.planned.id,
91
- personaId: spec.persona.id,
92
- device: spec.planned.device,
93
- }),
94
+ label: caption,
94
95
  status: view.status,
95
96
  ...(judged === undefined ? {} : { judgedStatus: judged }),
96
97
  transport: "snapshot",
@@ -99,9 +100,9 @@ function sharedWorldStream(ctx, spec, index, view) {
99
100
  ? {
100
101
  kind: "screenshot",
101
102
  url: lastScreenshot,
102
- title: `Shared plane, persona ${spec.planned.id} (${screenshotMode})`,
103
+ title: `${caption} (${screenshotMode})`,
103
104
  }
104
- : { kind: "placeholder", title: `Shared plane, persona ${spec.planned.id}` },
105
+ : { kind: "placeholder", title: caption },
105
106
  ...(desktopGeometry.viewport === undefined
106
107
  ? {}
107
108
  : {
@@ -252,7 +252,7 @@ function concurrencyReview(args, external, inProgress, evidence, events, nextEve
252
252
  const overlaps = args.judgment.world.overlap;
253
253
  const deltas = (stateSeries ?? []).filter((snapshot, i) => i > 0 && snapshot.digest !== (stateSeries ?? [])[i - 1].digest).length;
254
254
  const stateSeriesLabel = external
255
- ? "stateSeries omitted (no authoritative shared-state proof on the external-public plane)"
255
+ ? "Shared app state was not recorded because the app is hosted outside humanish"
256
256
  : `stateSeries ${plural((stateSeries ?? []).length, "snapshot")}, ${plural(deltas, "delta")}`;
257
257
  const convergenceLabel = external
258
258
  ? `; lobby convergence ${args.lobbyConvergenceDigest ? "shown (all participants reached one /lobby/CODE)" : "not observed"}`
@@ -13,7 +13,7 @@ import { estimateActorCostForExecution } from "./pricing.js";
13
13
  import { resolveRunPath } from "./locate.js";
14
14
  import { RUN_BUNDLE_FILE } from "./bundle.js";
15
15
  import { savedCaption } from "./participant-caption.js";
16
- import { streamParticipantIdOf } from "./participant-records.js";
16
+ import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
17
17
  import { readContainedRegularFile } from "./contained-output.js";
18
18
  import { isPathInside, resolvePhysicalCwd } from "./paths.js";
19
19
  const RUN_DETAIL_SCHEMA = "humanish.run-detail.v1";
@@ -43,12 +43,12 @@ function latestThought(trace) {
43
43
  }
44
44
  return undefined;
45
45
  }
46
- function participantFrom(stream, index) {
46
+ function participantFrom(stream, index, savedPersonaId) {
47
47
  // A live run's flush wins over the finished trace: while both exist, the live one is the newer
48
48
  // account of what is happening.
49
49
  const trace = stream.liveActor ?? stream.actor ?? {};
50
50
  const id = stream.id ?? `stream-${index + 1}`;
51
- const personaId = trace.persona?.id;
51
+ const personaId = trace.persona?.id ?? savedPersonaId;
52
52
  const participantId = streamParticipantIdOf(stream);
53
53
  // Computed once: calling it twice to test-then-use reads as though the two could differ.
54
54
  const thought = latestThought(trace);
@@ -140,7 +140,7 @@ export async function readRunDetail(cwdInput, runId) {
140
140
  ...(automaticAnalysis === undefined ? {} : { automaticAnalysis }),
141
141
  schema: RUN_DETAIL_SCHEMA,
142
142
  runId: bundle.runId ?? runId,
143
- participants: streams.map(participantFrom),
143
+ participants: streams.map((stream, index) => participantFrom(stream, index, recordedPersonaId(bundle, stream))),
144
144
  ...(isPathInside(cwd, observerAbsolute)
145
145
  ? { observerPath: path.relative(cwd, observerAbsolute) }
146
146
  : { observerPath: observerAbsolute }),
@@ -25,6 +25,7 @@ export interface SavedCaptionFacts {
25
25
  * The caption a saved stream shows: its label, which a route writes with `participantCaption`.
26
26
  * Earlier releases wrote ids and taxonomy into the label (`lane-01 · browser`, `CUA participant
27
27
  * <id>: <study>`, `Concurrent persona <id> (type:<t> / ...) · <study>`), so a stream saved that way
28
- * is captioned again from the id it names, without a device.
28
+ * is captioned again from the id it names. Older terminal and scripted labels use the
29
+ * recorded persona; a scripted mobile surface also identifies a phone.
29
30
  */
30
31
  export declare function savedCaption(stream: SavedCaptionFacts): string;
@@ -28,11 +28,13 @@ export function participantCaption(participant) {
28
28
  }
29
29
  const OLDER_SHARED_WORLD_LABEL = /^Concurrent persona (\S+)/;
30
30
  const OLDER_COMPUTER_USE_LABEL = /^CUA |^\S+ · browser$/;
31
+ const OLDER_TERMINAL_OR_SCRIPTED_LABEL = /^(Terminal agent|Desktop browser surface|Mobile browser surface) · /;
31
32
  /**
32
33
  * The caption a saved stream shows: its label, which a route writes with `participantCaption`.
33
34
  * Earlier releases wrote ids and taxonomy into the label (`lane-01 · browser`, `CUA participant
34
35
  * <id>: <study>`, `Concurrent persona <id> (type:<t> / ...) · <study>`), so a stream saved that way
35
- * is captioned again from the id it names, without a device.
36
+ * is captioned again from the id it names. Older terminal and scripted labels use the
37
+ * recorded persona; a scripted mobile surface also identifies a phone.
36
38
  */
37
39
  export function savedCaption(stream) {
38
40
  const { label, participantId, personaId } = stream;
@@ -40,6 +42,14 @@ export function savedCaption(stream) {
40
42
  if (sharedWorldId !== undefined) {
41
43
  return participantCaption({ id: sharedWorldId, personaId: personaId ?? sharedWorldId });
42
44
  }
45
+ if (OLDER_TERMINAL_OR_SCRIPTED_LABEL.test(label) && personaId !== undefined) {
46
+ return participantCaption({
47
+ personaId,
48
+ ...(label.startsWith("Mobile browser surface · ")
49
+ ? { device: { name: "mobile", preset: { isMobile: true } } }
50
+ : {}),
51
+ });
52
+ }
43
53
  if (OLDER_COMPUTER_USE_LABEL.test(label) && personaId !== undefined) {
44
54
  return participantCaption({
45
55
  ...(participantId === undefined ? {} : { id: participantId }),
@@ -1,4 +1,4 @@
1
- import type { RunEvent, RunProviderResource, RunSimulation } from "./bundle.js";
1
+ import type { RunBundle, RunEvent, RunProviderResource, RunSimulation } from "./bundle.js";
2
2
  import type { RunStream } from "./streams.js";
3
3
  /** The ids a participant's saved records carry: its record id and its stream id. */
4
4
  export interface ParticipantIds {
@@ -26,3 +26,5 @@ export declare function recordIdOf(stream: RunStream): string;
26
26
  export declare function eventRecordIdOf(event: RunEvent): string | undefined;
27
27
  /** The participant id an adapter recorded on a saved stream (a fan-out `lane-NN` id), when it has one. */
28
28
  export declare function streamParticipantIdOf(stream: Pick<RunStream, "laneId">): string | undefined;
29
+ /** The persona recorded for a stream, including dry runs without an actor trace. */
30
+ export declare function recordedPersonaId(bundle: Partial<Pick<RunBundle, "simulations">>, stream: Partial<Pick<RunStream, "simId">>): string | undefined;
@@ -61,3 +61,9 @@ export function eventRecordIdOf(event) {
61
61
  export function streamParticipantIdOf(stream) {
62
62
  return stream.laneId;
63
63
  }
64
+ /** The persona recorded for a stream, including dry runs without an actor trace. */
65
+ export function recordedPersonaId(bundle, stream) {
66
+ if (stream.simId === undefined || !Array.isArray(bundle.simulations))
67
+ return undefined;
68
+ return bundle.simulations.find((record) => record?.id === stream.simId)?.personaId;
69
+ }
package/dist/run-study.js CHANGED
@@ -4,6 +4,9 @@
4
4
  // which runs the route's local checks that need no scorer (keys, subject env, the local agent) and
5
5
  // returns the route's run. prepareStudy is the same path in two steps, so the CLI can present either
6
6
  // refusal before it loads a declared review scorer.
7
+ import { participantList } from "./study/study-fields.js";
8
+ import { personaBackgroundWarnings } from "./study/warnings.js";
9
+ import { resolveCommittedPersonasForCwd, studyPersonaIds } from "./study/persona-resolve.js";
7
10
  import { computerUseInput, scriptedInput, sharedWorldInput, terminalInput, } from "./study/route-inputs.js";
8
11
  import { planStudy, resolveStudyDryRun, routeOf } from "./study/plan.js";
9
12
  import { normalizeRunStudyOptions, optionRefusalOutcome, removedOptionRefusal, } from "./study/run-study-options.js";
@@ -70,7 +73,9 @@ export async function prepareStudy(config, options, deps = {}) {
70
73
  outcome.result.warnings.push(...normalized.warnings);
71
74
  return { ok: false, outcome };
72
75
  }
73
- const admitted = await admitPlan(study, planning, planned.planned.plan, deps, normalized.emit);
76
+ const runWarnings = [...(planned.planned.plan.warnings ?? [])];
77
+ const plan = { ...planned.planned.plan, warnings: runWarnings };
78
+ const admitted = await admitPlan(study, planning, plan, deps, normalized.emit);
74
79
  if (!admitted.ok) {
75
80
  await vm?.close();
76
81
  admitted.outcome.result.warnings.push(...normalized.warnings);
@@ -92,6 +97,17 @@ export async function prepareStudy(config, options, deps = {}) {
92
97
  return outcome;
93
98
  }
94
99
  }
100
+ // Admission keeps its refusal order; these warnings join the plan before its run scope opens.
101
+ const personas = route === "computer-use" || route === "shared-world"
102
+ ? (await resolveCommittedPersonasForCwd(options.cwd, studyPersonaIds(study))).personas
103
+ : new Map();
104
+ const backgroundWarnings = personaBackgroundWarnings(route === "computer-use" &&
105
+ options.count !== undefined &&
106
+ participantList(study) === undefined
107
+ ? { ...study, participants: options.count }
108
+ : study, personas);
109
+ runWarnings.splice(0, runWarnings.length, ...(planned.planned.plan.warnings ?? []), ...backgroundWarnings);
110
+ normalized.warnings.push(...backgroundWarnings);
95
111
  // The route layers the scorer over the inputs it admitted, and the local study is not
96
112
  // rebuilt for it. The admitted runSession, provider and participant desktop belong to this
97
113
  // study, so a rebuilt one would leave the participants they start for no finally to close.
@@ -1,3 +1,4 @@
1
+ import { personaBackgroundWarnings } from "./warnings.js";
1
2
  import { studyPersonaIds, resolveCommittedPersonasForCwd } from "./persona-resolve.js";
2
3
  import { personaBrief, PersonaConfigError } from "./persona.js";
3
4
  import { constants } from "node:fs";
@@ -193,7 +194,11 @@ export async function inspectStudyManifest(cwd, study) {
193
194
  config: resolved.config,
194
195
  origin: resolved.origin,
195
196
  path: resolved.path,
196
- warnings: [...resolved.warnings, ...personaResolution.warnings],
197
+ warnings: [
198
+ ...resolved.warnings,
199
+ ...personaResolution.warnings,
200
+ ...personaBackgroundWarnings(resolved.config, personaResolution.personas),
201
+ ],
197
202
  };
198
203
  }
199
204
  function parseResolvedStudy(args) {
@@ -65,6 +65,8 @@ export declare function studySetupChecks(args: StudySetupCheckArgs): Promise<{
65
65
  keys: string[];
66
66
  reads?: ReadonlySet<string>;
67
67
  checks: Check[];
68
+ /** The step to take first, which doctor prints before its rows. */
69
+ next?: string;
68
70
  }>;
69
71
  /**
70
72
  * The ids of the project's labs that need each provider key for a live run, from the same plan
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
3
3
  import { localRuntimeStatus } from "../substrates/local/runtime.js";
4
+ import { capacityShortfall, describeCapacity, } from "../substrates/local/capacity.js";
4
5
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
5
6
  import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
6
7
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
@@ -178,8 +179,11 @@ export async function studySetupChecks(args) {
178
179
  // One account check, shared by the local participant row and the Codex analysis row.
179
180
  let accountReadiness;
180
181
  const checkAccount = () => (accountReadiness ??= (args.codexAnalysisReadiness ?? codexAccountReadiness)(args.env).catch(() => ({ ready: false, errorCode: "codex_unavailable" })));
181
- if (local)
182
- checks.push(...(await localBrowserChecks(config, args)));
182
+ const plan = planned.planned.plan;
183
+ const desktopsAtOnce = plan.route === "computer-use" ? plan.concurrency : undefined;
184
+ const localChecks = local ? await localBrowserChecks(config, args, desktopsAtOnce) : undefined;
185
+ if (localChecks !== undefined)
186
+ checks.push(...localChecks.checks);
183
187
  if (config.comms?.email?.kind === "real")
184
188
  checks.push(await realEmailCheck(config.comms.email.connection, keys, args));
185
189
  checks.push(...(await participantChecks(config, route, keys, local, args, checkAccount)));
@@ -193,10 +197,19 @@ export async function studySetupChecks(args) {
193
197
  const reads = config.review?.scorer === undefined
194
198
  ? new Set([...keyNamesOf(planned.planned.plan), ...keys])
195
199
  : undefined;
196
- return { desktop, keys, ...(reads === undefined ? {} : { reads }), checks };
200
+ return {
201
+ desktop,
202
+ keys,
203
+ ...(reads === undefined ? {} : { reads }),
204
+ checks,
205
+ ...(localChecks?.next === undefined ? {} : { next: localChecks.next }),
206
+ };
197
207
  }
198
- /** A local browser study's runtime and, with an external catch, its captured inbox. */
199
- async function localBrowserChecks(config, args) {
208
+ /**
209
+ * A local browser study's runtime, how many of its desktops fit and, with an external catch, its
210
+ * captured inbox. `next` is the runtime's message when it is not ready, which doctor leads with.
211
+ */
212
+ async function localBrowserChecks(config, args, desktopsAtOnce) {
200
213
  const checks = [];
201
214
  const runtime = await (args.localRuntimeReadiness ??
202
215
  (() => localRuntimeStatus({
@@ -204,7 +217,14 @@ async function localBrowserChecks(config, args) {
204
217
  media: config.execution?.desktop?.media !== undefined ||
205
218
  config.execution?.desktop?.recording !== undefined,
206
219
  })))();
207
- checks.push({ name: "local browser runtime", ok: runtime.ok, message: runtime.message });
220
+ checks.push({
221
+ name: "local browser runtime",
222
+ ok: runtime.ok,
223
+ message: runtime.message,
224
+ ...(runtime.ok && !runtime.installed ? { status: "note" } : {}),
225
+ });
226
+ if (runtime.capacity !== undefined && desktopsAtOnce !== undefined)
227
+ checks.push(capacityCheck(runtime.capacity, desktopsAtOnce));
208
228
  const email = config.comms?.email;
209
229
  if (email?.kind === "fake" && email.external) {
210
230
  const healthy = await externalCatchHealthy(email.external, { timeoutMs: 5000 });
@@ -216,7 +236,25 @@ async function localBrowserChecks(config, args) {
216
236
  : `Captured inbox is unavailable or outdated. Start or upgrade and restart ${cli("comms catch")}, then check comms.email.external.catchBaseUrl (and inboxBaseUrl if set). No participant was allocated.`,
217
237
  });
218
238
  }
219
- return checks;
239
+ return {
240
+ checks,
241
+ ...(runtime.ok && runtime.installed ? {} : { next: runtime.message }),
242
+ };
243
+ }
244
+ /** Whether the desktops the study runs at once fit the VM or this machine, and why. */
245
+ function capacityCheck(capacity, desktopsAtOnce) {
246
+ const shortfall = capacityShortfall(capacity, desktopsAtOnce);
247
+ const name = "local desktop capacity";
248
+ if (shortfall === undefined)
249
+ return {
250
+ name,
251
+ ok: true,
252
+ message: `${describeCapacity(capacity)} This study runs ${desktopsAtOnce} at once, so it fits.`,
253
+ };
254
+ // Linux runs a study that is short of memory with a warning, so the row is advice there.
255
+ return "refusal" in shortfall
256
+ ? { name, ok: false, message: shortfall.refusal }
257
+ : { name, ok: true, status: "note", message: shortfall.warning };
220
258
  }
221
259
  /** The saved real email connection. Adds the key it needs to `keys`. */
222
260
  async function realEmailCheck(connection, keys, args) {
@@ -159,9 +159,11 @@ export const starterFiles = [
159
159
  id: synthetic-new-user
160
160
  name: Synthetic New User
161
161
  summary: A privacy-safe first-time user evaluating the app with realistic but synthetic needs.
162
- traits:
163
- patience: medium
164
- technical_confidence: medium
162
+ background: >-
163
+ Alex keeps personal plans in a notebook and uses email and a few everyday apps without trouble.
164
+ They are trying an unfamiliar app to see whether it can make everyday tasks easier. They work
165
+ through some friction but stop when more effort no longer seems worth it, and they take the
166
+ straightforward path the screen offers.
165
167
  constraints:
166
168
  - Do not use real personal data.
167
169
  - Do not use production accounts.
@@ -175,9 +177,12 @@ constraints:
175
177
  id: skeptical-power-user
176
178
  name: Skeptical Power User
177
179
  summary: A privacy-safe experienced user looking for speed, reversibility, and clear proof.
180
+ background: >-
181
+ Robin manages several volunteer projects with spreadsheets and keyboard shortcuts and is at ease
182
+ with advanced options. They want faster tools but have lost work before and care about undoing
183
+ mistakes. They have little patience for repeated friction and stop when a tool keeps getting in
184
+ the way.
178
185
  traits:
179
- patience: low
180
- technical_confidence: high
181
186
  accessibility_needs: keyboard_first
182
187
  constraints:
183
188
  - Do not use real personal data.
@@ -12,7 +12,7 @@ import { planBase } from "./plan-base.js";
12
12
  import { routeOf } from "./routing.js";
13
13
  import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
14
14
  import { declaredParticipantCount } from "./study-fields.js";
15
- import { egressAllowIgnoredWarning } from "./warnings.js";
15
+ import { egressAllowIgnoredWarning, scriptedMissionWarnings } from "./warnings.js";
16
16
  export { routeOf } from "./routing.js";
17
17
  /** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
18
18
  export function resolveStudyDryRun(config, override, fallback) {
@@ -76,10 +76,11 @@ export function planStudy(config, options, deps = {}) {
76
76
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
77
77
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
78
78
  const warning = egressAllowIgnoredWarning(study);
79
+ const warnings = [...(warning === undefined ? [] : [warning]), ...scriptedMissionWarnings(study)];
79
80
  const plan = {
80
81
  ...result.plan,
81
82
  ...(options.study === undefined ? {} : { study: options.study }),
82
- ...(warning === undefined ? {} : { warnings: [warning] }),
83
+ ...(warnings.length === 0 ? {} : { warnings }),
83
84
  };
84
85
  return { ok: true, planned: { plan } };
85
86
  }
@@ -3,6 +3,7 @@ import type { StudyPreflightCheck, StudyPreflightResult, PreflightContext } from
3
3
  export declare const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
4
4
  export declare function finalize(ctx: PreflightContext, args?: {
5
5
  check?: StudyPreflightCheck;
6
+ checks?: StudyPreflightCheck[];
6
7
  }): StudyPreflightResult;
7
8
  export declare function fail(ctx: PreflightContext, code: NonNullable<StudyPreflightResult["error"]>["code"], message: string, checks: StudyPreflightCheck[]): StudyPreflightResult;
8
9
  export declare function digest(value: string): string;
@@ -5,7 +5,7 @@ import { digestText } from "../evidence/redaction.js";
5
5
  /** The schema of `study check` results: whether a study is ready to run on this machine. */
6
6
  export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
7
7
  export function finalize(ctx, args) {
8
- const checks = args?.check ? [...ctx.checks, args.check] : ctx.checks;
8
+ const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
9
9
  const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route);
10
10
  return {
11
11
  schema: STUDY_CHECK_SCHEMA,
@@ -9,6 +9,8 @@ export interface StudyPreflightCheck {
9
9
  name: string;
10
10
  ok: boolean;
11
11
  message: string;
12
+ /** `false` on a row the CLI could not check; its message names the command that checks it. */
13
+ checked?: false;
12
14
  }
13
15
  export interface StudyPreflightTarget {
14
16
  label: string;