humanish 0.110.1 → 0.110.3

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 (121) hide show
  1. package/ARCHITECTURE.md +11 -16
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +8 -7
  5. package/dist/actors/computer-use/actor.js +2 -2
  6. package/dist/actors/computer-use/loop/ending.js +1 -1
  7. package/dist/actors/local-agent/cli.d.ts +6 -0
  8. package/dist/actors/local-agent/cli.js +8 -0
  9. package/dist/actors/local-agent/readiness.js +2 -1
  10. package/dist/actors/registry.js +1 -1
  11. package/dist/analysis/automatic-completion.js +8 -0
  12. package/dist/cli/commands/observe.js +18 -4
  13. package/dist/cli/commands/run-signals.js +13 -4
  14. package/dist/cli/commands/runs.js +36 -13
  15. package/dist/cli/commands/setup.js +23 -14
  16. package/dist/cli/commands/study-route-computer-use.js +7 -3
  17. package/dist/cli/commands/study-route-open.js +1 -0
  18. package/dist/cli/commands/study-route-preview.js +1 -0
  19. package/dist/cli/commands/study-route-shared-world.js +4 -2
  20. package/dist/cli/commands/study-run.js +9 -19
  21. package/dist/cli/commands/watch.js +14 -4
  22. package/dist/cli/doctor.js +9 -6
  23. package/dist/cli/findings.d.ts +5 -0
  24. package/dist/cli/findings.js +28 -6
  25. package/dist/cli/first-run-path.d.ts +27 -7
  26. package/dist/cli/first-run-path.js +112 -75
  27. package/dist/cli/invocation.d.ts +7 -0
  28. package/dist/cli/invocation.js +32 -0
  29. package/dist/cli/io.d.ts +5 -2
  30. package/dist/cli/io.js +9 -16
  31. package/dist/cli/observer-follow.d.ts +24 -0
  32. package/dist/cli/observer-follow.js +48 -3
  33. package/dist/cli/orientation.js +16 -14
  34. package/dist/cli/version.d.ts +1 -0
  35. package/dist/cli/version.js +11 -0
  36. package/dist/evidence/encoded-text.d.ts +9 -6
  37. package/dist/evidence/encoded-text.js +84 -19
  38. package/dist/evidence/redaction.d.ts +2 -0
  39. package/dist/evidence/redaction.js +134 -21
  40. package/dist/feedback/export-bundle.js +2 -2
  41. package/dist/keys/key-resolution.js +6 -5
  42. package/dist/observer/exposure.js +1 -1
  43. package/dist/observer/library.js +3 -2
  44. package/dist/observer/render.js +2 -1
  45. package/dist/observer/run-routes.js +8 -1
  46. package/dist/observer/serve.js +2 -1
  47. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  48. package/dist/routes/computer-use/participant-runs.js +16 -36
  49. package/dist/routes/computer-use/plan.d.ts +2 -2
  50. package/dist/routes/computer-use/plan.js +6 -1
  51. package/dist/routes/computer-use/route.d.ts +5 -18
  52. package/dist/routes/computer-use/route.js +17 -78
  53. package/dist/routes/computer-use/setup.d.ts +3 -2
  54. package/dist/routes/computer-use/types.d.ts +6 -10
  55. package/dist/routes/scripted/route.d.ts +4 -17
  56. package/dist/routes/scripted/route.js +10 -42
  57. package/dist/routes/scripted/types.d.ts +1 -6
  58. package/dist/routes/shared-world/plan.js +5 -1
  59. package/dist/routes/shared-world/route.d.ts +5 -11
  60. package/dist/routes/shared-world/route.js +24 -63
  61. package/dist/routes/shared-world/types.d.ts +2 -6
  62. package/dist/routes/terminal/bundle.js +2 -1
  63. package/dist/routes/terminal/credentials.d.ts +5 -5
  64. package/dist/routes/terminal/credentials.js +14 -6
  65. package/dist/routes/terminal/plan.js +8 -0
  66. package/dist/routes/terminal/route.d.ts +3 -13
  67. package/dist/routes/terminal/route.js +24 -63
  68. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  69. package/dist/routes/terminal/runtime-auth.js +5 -0
  70. package/dist/routes/terminal/session.d.ts +2 -3
  71. package/dist/routes/terminal/session.js +2 -3
  72. package/dist/routes/terminal/types.d.ts +2 -7
  73. package/dist/run/dry-run.js +2 -1
  74. package/dist/run/judge.js +2 -1
  75. package/dist/run/local-only-files.d.ts +13 -0
  76. package/dist/run/local-only-files.js +35 -0
  77. package/dist/run/reclaim.d.ts +5 -0
  78. package/dist/run/reclaim.js +34 -2
  79. package/dist/run/route-shell.d.ts +60 -0
  80. package/dist/run/route-shell.js +51 -0
  81. package/dist/run/run-not-found.js +3 -2
  82. package/dist/run/run.d.ts +6 -1
  83. package/dist/run/run.js +11 -2
  84. package/dist/run/sandbox-creates.js +5 -3
  85. package/dist/run/sandbox-receipts.d.ts +6 -5
  86. package/dist/run/sandbox-receipts.js +12 -7
  87. package/dist/run/status.d.ts +2 -0
  88. package/dist/run/status.js +5 -0
  89. package/dist/study/agents-section.d.ts +21 -0
  90. package/dist/study/agents-section.js +86 -0
  91. package/dist/study/composition-rules.js +1 -1
  92. package/dist/study/doctor.js +5 -4
  93. package/dist/study/init-templates.d.ts +1 -1
  94. package/dist/study/init-templates.js +43 -129
  95. package/dist/study/init.js +32 -35
  96. package/dist/study/local-browser-host.d.ts +11 -0
  97. package/dist/study/local-browser-host.js +42 -0
  98. package/dist/study/parse/actors.js +9 -1
  99. package/dist/study/parse/subject.js +13 -1
  100. package/dist/study/parse/url-credentials.d.ts +9 -0
  101. package/dist/study/parse/url-credentials.js +50 -0
  102. package/dist/study/preflight-probes.js +2 -1
  103. package/dist/study/types.d.ts +8 -6
  104. package/dist/study/url-credentials.d.ts +6 -0
  105. package/dist/study/url-credentials.js +34 -0
  106. package/dist/substrates/e2b/sandbox.js +33 -15
  107. package/dist/substrates/local/firecracker-desktop.js +3 -2
  108. package/dist/substrates/local/runtime-host.js +5 -4
  109. package/dist/substrates/local/runtime.js +5 -4
  110. package/dist/tui/contract.js +5 -4
  111. package/dist/verify/liveness.js +2 -1
  112. package/dist/verify/verify.js +2 -1
  113. package/docs/architecture/actor-contract.md +1 -1
  114. package/docs/architecture/external-public-shared-world.md +1 -1
  115. package/docs/architecture/terminal-product-route.md +26 -24
  116. package/docs/contracts/run-bundle.md +20 -4
  117. package/docs/contracts/schemas.md +6 -5
  118. package/docs/contracts/study-analysis.md +5 -2
  119. package/docs/product/automatic-analysis.md +1 -1
  120. package/package.json +4 -2
  121. package/skills/humanish/SKILL.md +22 -18
@@ -1,6 +1,8 @@
1
- // Every created sandbox id is journaled to the run dir the moment create returns, before any work
2
- // happens in it. The participant loop lives in the caller's local process, so a sleeping laptop, crash or
3
- // kill loses every in-memory id; this append-only file keeps them for `humanish reclaim`.
1
+ // Every created sandbox id is journaled to the run dir as soon as it reaches this process: when the
2
+ // desktop startup guard reports it, or when create returns, before any work happens in it. The
3
+ // participant loop lives in the caller's local process, so a sleeping laptop, crash or kill loses
4
+ // every in-memory id; this append-only file keeps them for `humanish reclaim`, and tells verify and
5
+ // the sweep which ids to look for.
4
6
  import { appendFile } from "node:fs/promises";
5
7
  import { prepareContainedOutputFile } from "./contained-output.js";
6
8
  export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
@@ -22,10 +24,11 @@ export function appendedSandboxIds(root) {
22
24
  return [...(appended.get(rootKey(root)) ?? [])];
23
25
  }
24
26
  /**
25
- * Append one receipt. Best-effort by design: the receipt exists to protect the run, so a failed
26
- * receipt write must never fail the participant. The only cost of a miss is that `reclaim` cannot see
27
- * this id and the TTL backstop covers it instead. Containment is the same prepare step every
28
- * artifact write uses; append (not atomic-replace) keeps racing participants' receipts intact.
27
+ * Append one receipt; false when the write failed. Best-effort by design: the receipt exists to
28
+ * protect the run, so a failed receipt write must never fail the participant. The only cost of a
29
+ * miss is that `reclaim` cannot see this id and the TTL backstop covers it instead. Containment is
30
+ * the same prepare step every artifact write uses; append (not atomic-replace) keeps racing
31
+ * participants' receipts intact.
29
32
  */
30
33
  export async function appendSandboxReceipt(root, receipt) {
31
34
  const key = rootKey(root);
@@ -40,9 +43,11 @@ export async function appendSandboxReceipt(root, receipt) {
40
43
  try {
41
44
  const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
42
45
  await appendFile(filePath, `${JSON.stringify(receipt)}\n`, "utf8");
46
+ return true;
43
47
  }
44
48
  catch {
45
49
  // Swallowed on purpose; see the contract above.
50
+ return false;
46
51
  }
47
52
  }
48
53
  /** Append one owner line; false when the write failed, which never fails the create. */
@@ -80,6 +80,8 @@ export interface RunStatusHandle {
80
80
  * `running` or `finished` over it. When the run had finished it writes nothing, waits for the
81
81
  * writes already queued and resolves false. */
82
82
  interrupt(signal: RunInterruptSignal): Promise<boolean>;
83
+ /** True from the moment `interrupt` accepts a signal, before its write lands. */
84
+ readonly interrupted: boolean;
83
85
  }
84
86
  export interface BeginRunStatusOptions {
85
87
  runId: string;
@@ -69,6 +69,7 @@ export function beginRunStatus(runPaths, options) {
69
69
  updatedAt: startedAt,
70
70
  };
71
71
  let finished = false;
72
+ let interrupted = false;
72
73
  let finishedRecord;
73
74
  let writing = Promise.resolve();
74
75
  const write = (record) => {
@@ -119,11 +120,15 @@ export function beginRunStatus(runPaths, options) {
119
120
  return false;
120
121
  }
121
122
  finished = true;
123
+ interrupted = true;
122
124
  clearInterval(timer);
123
125
  const completedAt = iso();
124
126
  await write({ ...base, state: "interrupted", updatedAt: completedAt, completedAt, signal });
125
127
  return true;
126
128
  },
129
+ get interrupted() {
130
+ return interrupted;
131
+ },
127
132
  };
128
133
  return handle;
129
134
  }
@@ -0,0 +1,21 @@
1
+ export type AgentsSectionReplacement =
2
+ /** The file has no humanish section. */
3
+ {
4
+ kind: "absent";
5
+ }
6
+ /** A start marker with no end marker, in text no release wrote: possibly hand-edited. */
7
+ | {
8
+ kind: "unknown";
9
+ }
10
+ /** The file with humanish's section replaced; equal to the input when it was already current. */
11
+ | {
12
+ kind: "replaced";
13
+ contents: string;
14
+ };
15
+ /**
16
+ * `existing` with humanish's section replaced by `section` (as `agentsSection()` returns it, with
17
+ * its leading blank line). The replaced range runs from the start of the heading line that holds
18
+ * the start marker to the end of the end marker's line, or, without an end marker, through the
19
+ * text of the release that wrote it.
20
+ */
21
+ export declare function replaceAgentsSection(existing: string, section: string): AgentsSectionReplacement;
@@ -0,0 +1,86 @@
1
+ // Finds humanish's own section in a project's `AGENTS.md` so init can replace it on a rerun and
2
+ // leave every other byte alone. Releases up to 0.110.1 wrote the section with a start marker and
3
+ // no end marker, so for those the section's extent is known only by matching a text init wrote.
4
+ import { createHash } from "node:crypto";
5
+ import { AGENTS_SECTION_END_MARKER, AGENTS_SECTION_MARKER } from "../cli/first-run-path.js";
6
+ /**
7
+ * Each section text a release wrote before the end marker existed, from its `## humanish` heading
8
+ * to its final newline: the UTF-16 length and the sha256 of its UTF-8 bytes. Comparing digests
9
+ * keeps the retired command and key names those versions taught out of the source.
10
+ */
11
+ const SECTIONS_WITHOUT_END_MARKER = [
12
+ {
13
+ releases: "0.59.0 to 0.80.0",
14
+ length: 1171,
15
+ sha256: "3739c98204cc5b2df603238398bffc5c4a491782a3ab72475b0ad9d967698a11",
16
+ },
17
+ {
18
+ releases: "0.81.0 to 0.93.1",
19
+ length: 1191,
20
+ sha256: "169d646f329f35af1b5e51eb02cd0454a717cf29a447a230f3aa2d5a7354729c",
21
+ },
22
+ {
23
+ releases: "0.94.0 to 0.99.1",
24
+ length: 1214,
25
+ sha256: "68f987769893f041ab78e0b1cb4cd8db14a438511f15b58f9eaa412ea002b51f",
26
+ },
27
+ {
28
+ releases: "0.100.0 to 0.105.0",
29
+ length: 1548,
30
+ sha256: "f60c37003d21b1d8280d002f203130c60bc3c23454b018b3207407faa01cb76f",
31
+ },
32
+ {
33
+ releases: "0.106.0 to 0.107.0",
34
+ length: 1686,
35
+ sha256: "c2cd9d831e17021ad4477f300dbea13df1613bec8f30ebbabfabcd749b73f7f7",
36
+ },
37
+ {
38
+ releases: "0.108.0",
39
+ length: 1679,
40
+ sha256: "6352ec71d7c5073b2bf4993293753944eaf7cfd21169a311f42d9f52bdacfead",
41
+ },
42
+ {
43
+ releases: "0.109.0 to 0.110.0",
44
+ length: 1681,
45
+ sha256: "2559a93463cf50225aadf6753e84dd6e32b25aa3afdf8066e90d19c5d88d0ef0",
46
+ },
47
+ {
48
+ releases: "0.110.1",
49
+ length: 2014,
50
+ sha256: "9fb4cb94400c7447ab0fece861339e50de8e9be99797cf0763d7502be0fe668d",
51
+ },
52
+ ];
53
+ /**
54
+ * `existing` with humanish's section replaced by `section` (as `agentsSection()` returns it, with
55
+ * its leading blank line). The replaced range runs from the start of the heading line that holds
56
+ * the start marker to the end of the end marker's line, or, without an end marker, through the
57
+ * text of the release that wrote it.
58
+ */
59
+ export function replaceAgentsSection(existing, section) {
60
+ const marker = existing.indexOf(AGENTS_SECTION_MARKER);
61
+ if (marker < 0)
62
+ return { kind: "absent" };
63
+ const start = existing.lastIndexOf("\n", marker) + 1;
64
+ const replacement = section.replace(/^\n/, "");
65
+ const endMarker = existing.indexOf(AGENTS_SECTION_END_MARKER, marker);
66
+ if (endMarker >= 0) {
67
+ const lineEnd = existing.indexOf("\n", endMarker);
68
+ const end = lineEnd < 0 ? existing.length : lineEnd + 1;
69
+ return {
70
+ kind: "replaced",
71
+ contents: existing.slice(0, start) + replacement + existing.slice(end),
72
+ };
73
+ }
74
+ for (const known of SECTIONS_WITHOUT_END_MARKER) {
75
+ const candidate = existing.slice(start, start + known.length);
76
+ if (candidate.length !== known.length)
77
+ continue;
78
+ if (createHash("sha256").update(candidate).digest("hex") !== known.sha256)
79
+ continue;
80
+ return {
81
+ kind: "replaced",
82
+ contents: existing.slice(0, start) + replacement + existing.slice(start + known.length),
83
+ };
84
+ }
85
+ return { kind: "unknown" };
86
+ }
@@ -51,7 +51,7 @@ function thisRepoValidationReason(config) {
51
51
  // desktop), and no public-target policy (it is always loopback; the loopback shape was already
52
52
  // enforced in parseSubject). The actual "no inProcess executor supplied" case is inherently an
53
53
  // engine-time decision (the parser cannot know whether a library caller will pass one), so
54
- // it fails closed in runCuaActorStudy with HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR.
54
+ // planComputerUseStudy refuses it with HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR.
55
55
  function localAppValidationReason(config) {
56
56
  if (config.subject.source === "local-app") {
57
57
  const type = config.actors[0]?.type ?? "";
@@ -2,12 +2,13 @@ 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
4
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
5
- import { localAgentVersionRefusal } from "../actors/local-agent/cli.js";
5
+ import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
6
6
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
7
7
  import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
8
8
  import { externalCatchHealthy } from "../comms/sandbox-catch.js";
9
9
  import { receivingRequiredKey } from "../comms/setup.js";
10
10
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
11
+ import { cli } from "../cli/invocation.js";
11
12
  /** A ready row's message, with the schema values the launch recorded. */
12
13
  function readyMessage(message, readiness) {
13
14
  const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
@@ -212,7 +213,7 @@ async function localBrowserChecks(config, args) {
212
213
  ok: healthy,
213
214
  message: healthy
214
215
  ? "Recipient inbox routes are ready. Point your app's email sends at this catch; delivery remains untested. No mailbox-provider credentials are needed, and this does not receive arbitrary internet mail."
215
- : "Captured inbox is unavailable or outdated. Start or upgrade and restart humanish comms catch, then check comms.email.external.catchBaseUrl (and inboxBaseUrl if set). No participant was allocated.",
216
+ : `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.`,
216
217
  });
217
218
  }
218
219
  return checks;
@@ -229,7 +230,7 @@ async function realEmailCheck(connection, keys, args) {
229
230
  ? "The selected email connection is missing or invalid. Open Connections in the TUI."
230
231
  : !args.keyPresent(name)
231
232
  ? `Missing ${name} for the selected email connection. Provide it through process env or --dotenv. Authentication has not been checked.`
232
- : "Fresh hosted inbox per participant. Local presence only; run humanish comms check --online to authenticate. Provider permissions/capacity and delivery remain untested.",
233
+ : `Fresh hosted inbox per participant. Local presence only; run ${cli("comms check --online")} to authenticate. Provider permissions/capacity and delivery remain untested.`,
233
234
  };
234
235
  }
235
236
  /** How the participant authenticates: the terminal model key, or a local agent's login. */
@@ -259,7 +260,7 @@ async function participantChecks(config, route, keys, local, args, checkAccount)
259
260
  return [
260
261
  {
261
262
  name: "local participant authentication",
262
- ok: agent?.authStatus === "authenticated" && outdated === undefined,
263
+ ok: agent !== undefined && localAgentReady(agent),
263
264
  message: agent?.authStatus === "authenticated"
264
265
  ? (outdated ??
265
266
  `${agent.label}${agent.version === undefined ? "" : ` ${agent.version}`} reports authenticated on the host. E2B supplies the desktop; no OpenAI API key is required for this participant.`)
@@ -24,5 +24,5 @@ export declare const humanishScripts: Record<string, string>;
24
24
  * a signed-in Codex and no provider key, the study runs with the operator's own agent and needs
25
25
  * only E2B. Writing the other one there would hand someone homework instead of a first run.
26
26
  */
27
- export declare function starterFilesFor(actor: StarterActor, localBrowser?: LocalBrowserStarter, localAgent?: LocalAgentId): StarterFile[];
27
+ export declare function starterFilesFor(actor: StarterActor, localBrowser?: LocalBrowserStarter, localAgent?: LocalAgentId, command?: string): StarterFile[];
28
28
  export {};
@@ -34,6 +34,44 @@ defaults:
34
34
  `,
35
35
  };
36
36
  }
37
+ /**
38
+ * The `humanish/README.md` init writes. Its commands start with `command`, the invocation init
39
+ * chose for this install (src/cli/invocation.ts), as in the section init writes to `AGENTS.md`.
40
+ */
41
+ function readmeFile(command) {
42
+ return {
43
+ path: "humanish/README.md",
44
+ plane: "source",
45
+ contents: `# humanish
46
+
47
+ This directory holds the committed definitions of this app's humanish studies: study files,
48
+ personas and scenarios.
49
+
50
+ Keep this directory public-safe:
51
+
52
+ - synthetic personas only;
53
+ - synthetic fixtures only;
54
+ - env var names only, never values;
55
+ - no PII, PHI, secrets, raw private transcripts, private screenshots, customer data, or patient data.
56
+
57
+ Generated run bundles, screenshots, traces, logs, and local overrides belong in ignored \`.humanish/\`.
58
+
59
+ Studies:
60
+
61
+ - committed reusable studies live in humanish/studies/*.yaml;
62
+ - private or machine-local studies live in ignored .humanish/studies/*.yaml or
63
+ .humanish/local/studies/*.yaml;
64
+ - run a study with \`${command} run <study>\`, or \`${command} watch <study>\` to stay attached;
65
+ - files under humanish/labs/ move there with \`${command} migrate\`.
66
+
67
+ Format standard:
68
+
69
+ - human-authored humanish source uses .yaml;
70
+ - generated artifacts, synthetic fixtures, and event streams use .json or .ndjson;
71
+ - .yml is reserved for outside ecosystem files such as GitHub Actions, not humanish source.
72
+ `,
73
+ };
74
+ }
37
75
  /**
38
76
  * The flagship live study. A ChatGPT-account Codex participant has no API-dollar price, so preflight
39
77
  * refuses a dollar cap on it (HUMANISH_COMPUTER_USE_UNPRICED_CAP); that variant is bounded by the
@@ -113,38 +151,7 @@ defaults:
113
151
  };
114
152
  }
115
153
  export const starterFiles = [
116
- {
117
- path: "humanish/README.md",
118
- plane: "source",
119
- contents: `# humanish
120
-
121
- This directory holds the committed definitions of this app's humanish studies: study files,
122
- personas and scenarios.
123
-
124
- Keep this directory public-safe:
125
-
126
- - synthetic personas only;
127
- - synthetic fixtures only;
128
- - env var names only, never values;
129
- - no PII, PHI, secrets, raw private transcripts, private screenshots, customer data, or patient data.
130
-
131
- Generated run bundles, screenshots, traces, logs, and local overrides belong in ignored \`.humanish/\`.
132
-
133
- Studies:
134
-
135
- - committed reusable studies live in humanish/studies/*.yaml;
136
- - private or machine-local studies live in ignored .humanish/studies/*.yaml or
137
- .humanish/local/studies/*.yaml;
138
- - run a study with \`humanish run <study>\`, or \`humanish watch <study>\` to stay attached;
139
- - files under humanish/labs/ move there with \`humanish migrate\`.
140
-
141
- Format standard:
142
-
143
- - human-authored humanish source uses .yaml;
144
- - generated artifacts, synthetic fixtures, and event streams use .json or .ndjson;
145
- - .yml is reserved for outside ecosystem files such as GitHub Actions, not humanish source.
146
- `,
147
- },
154
+ readmeFile("humanish"),
148
155
  {
149
156
  path: "humanish/personas/synthetic-new-user.yaml",
150
157
  plane: "source",
@@ -194,24 +201,6 @@ steps:
194
201
  expectation: The user sees a clear next state.
195
202
  - name: Capture review notes
196
203
  expectation: Notes are public-safe and evidence-backed.
197
- `,
198
- },
199
- {
200
- path: "humanish/scenarios/onboarding-regression.yaml",
201
- plane: "source",
202
- contents: `schema: humanish.scenario.v1
203
- id: onboarding-regression
204
- title: Onboarding regression
205
- persona: skeptical-power-user
206
- goal: Exercise onboarding friction using synthetic inputs and explicit recovery checks.
207
- mode: dry-run
208
- steps:
209
- - name: Start onboarding
210
- expectation: Required information is clear.
211
- - name: Use synthetic fixture data
212
- expectation: No real user data is entered.
213
- - name: Check recovery path
214
- expectation: The user can back out or retry safely.
215
204
  `,
216
205
  },
217
206
  {
@@ -308,83 +297,6 @@ execution:
308
297
  # allowPublicTargets: true # required to drive a non-loopback app-url (a deployment you own)
309
298
  defaults:
310
299
  open: true
311
- `,
312
- },
313
- {
314
- path: "humanish/studies/lobby-trivia-3player.yaml",
315
- plane: "source",
316
- contents: `schema: humanish.study.v3
317
- id: lobby-trivia-3player
318
- title: "Shared world on a public app: three mobile participants in one lobby"
319
- description: >-
320
- A worked example of the external-public shared-world route: three participants on mobile layouts
321
- play the same multiplayer lobby on a public deployment at the same time. The public site is the
322
- shared plane, so there is no clone, subject sandbox or seed. The host participant creates the
323
- lobby, humanish reads the session code from the host's page URL, and the other participants get
324
- it in their missions and join through the app's own join flow. Starts as a dry run, which writes
325
- one evidence bundle at no cost. A live run needs OPENAI_API_KEY, E2B_API_KEY and mode: live. Point appUrl and publicTarget at a public deployment you own or operate. Mobile layout:
326
- Chrome's physical window is at least 500px wide and has no touch emulation, desktop pixel ratio
327
- and a desktop user agent. Set execution.desktop.fidelity.mobileEmulation to true to request the
328
- preset's CSS viewport, touch, pixel ratio and mobile user agent on Chrome or Chromium; the bundle
329
- records the measured geometry and emulation fidelity. A run on a public site proves less than a
330
- provisioned one: its provenance is external-public, it carries no synthetic-data attestation and
331
- no authoritative shared-state proof, and concurrency rests on overlapping sessions and every
332
- participant reaching the same lobby.
333
- route: shared-world
334
- mode: dry-run # a live run opens three mobile-layout desktops against the public app
335
- subject:
336
- source: app-url # the public deployment is the shared plane: no clone and no getHost
337
- appUrl: https://your-public-app.example/ # a deployment you own or operate
338
- publicTarget: # required: your attestation that you own or operate this deployment
339
- owner: your-org/your-app
340
- authorized: true
341
- policies:
342
- allowPublicTargets: true # required: the shared plane is a public, non-loopback deployment
343
- actor:
344
- type: openai-computer-use
345
- mission: You are one of several users on the same shared session at once. Play your role, then stop.
346
- participants: # at least two participants, exactly one with host: true
347
- - id: host
348
- host: true # this participant creates the shared session
349
- device: mobile # 414x896 preset; physical window width floors to 500 (see emulation above)
350
- instruction: Create a shared lobby, wait for the others to join, then start and play.
351
- - id: player-2
352
- device: mobile
353
- instruction: Join the lobby you're told the code for, then play.
354
- - id: player-3
355
- device: small-mobile
356
- instruction: Join the lobby you're told the code for, then play.
357
- execution:
358
- target: e2b-desktop
359
- # Each participant's session budget, which also bounds the host's handoff deadline. It has to
360
- # cover provisioning, joining the shared session and play; 420000 ms (7 minutes) does.
361
- timeoutMs: 420000
362
- concurrency: 3 # all three participants at once, the default; lower it only to limit paid desktops
363
- # desktop:
364
- # fidelity: { mobileEmulation: true } # opt into mobile CSS viewport, touch, DPR and user agent
365
- defaults:
366
- open: true
367
- `,
368
- },
369
- {
370
- path: "humanish/coverage-map.md",
371
- plane: "source",
372
- contents: `# Coverage Map
373
-
374
- This file should enumerate screens, roles, states, and paths before scenarios are treated as complete.
375
-
376
- Current starter coverage is intentionally minimal and synthetic.
377
- `,
378
- },
379
- {
380
- path: "humanish/coverage-matrix.md",
381
- plane: "source",
382
- contents: `# Coverage Matrix
383
-
384
- | Area | Persona | Happy Path | Sad Path | Status |
385
- | --- | --- | --- | --- | --- |
386
- | First run | synthetic-new-user | planned | planned | starter |
387
- | Onboarding | skeptical-power-user | planned | planned | starter |
388
300
  `,
389
301
  },
390
302
  ];
@@ -412,10 +324,12 @@ export const humanishScripts = {
412
324
  * a signed-in Codex and no provider key, the study runs with the operator's own agent and needs
413
325
  * only E2B. Writing the other one there would hand someone homework instead of a first run.
414
326
  */
415
- export function starterFilesFor(actor, localBrowser = DEFAULT_LOCAL_BROWSER_STARTER, localAgent) {
327
+ export function starterFilesFor(actor, localBrowser = DEFAULT_LOCAL_BROWSER_STARTER, localAgent, command = "humanish") {
416
328
  return starterFiles.map((file) => file.path === "humanish/studies/local-browser.yaml"
417
329
  ? localBrowserStudy(localBrowser)
418
330
  : file.path === "humanish/studies/try-live.yaml"
419
331
  ? tryLiveStudy(actor, localAgent)
420
- : file);
332
+ : file.path === "humanish/README.md"
333
+ ? readmeFile(command)
334
+ : file);
421
335
  }
@@ -1,7 +1,10 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { AGENTS_SECTION_MARKER, agentsSection, firstRunGuidance, starterActorFor, starterLocalAgentFor, } from "../cli/first-run-path.js";
3
+ import { agentsSection, firstRunGuidance, starterActorFor, starterLocalAgentFor, } from "../cli/first-run-path.js";
4
4
  import { detectLocalAgents } from "../actors/local-agent/cli.js";
5
+ import { humanishCommand } from "../cli/invocation.js";
6
+ import { replaceAgentsSection } from "./agents-section.js";
7
+ import { probeLocalBrowserHost } from "./local-browser-host.js";
5
8
  import { probeKeySources } from "../keys/key-resolution.js";
6
9
  import { DEFAULT_LOCAL_BROWSER_STARTER, runtimeDirectories, starterFilesFor, } from "./init-templates.js";
7
10
  import { assertPreparedSelectedOutputDirectory, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
@@ -38,13 +41,13 @@ export async function runInit(options) {
38
41
  if (initialPathCheck) {
39
42
  return failed(initialPathCheck);
40
43
  }
41
- await planAgentsFile(preparedProjectRoot, cwd, plan);
42
44
  // The starter live study is written for the brain this machine can actually use. Shipping it as
43
45
  // openai-computer-use on a machine with no provider key but a signed-in Codex would hand someone
44
46
  // a file that asks for a credential they were just told they do not need.
45
47
  const machine = await firstRunEnvironment(options.env ?? process.env, requestedCwd);
48
+ await planAgentsFile(preparedProjectRoot, cwd, plan, humanishCommand());
46
49
  const starterActor = starterActorFor(machine);
47
- await planStarterFiles(preparedProjectRoot, cwd, starterFilesFor(starterActor, localBrowser.value, starterLocalAgentFor(machine)), options.localBrowser !== undefined, plan);
50
+ await planStarterFiles(preparedProjectRoot, cwd, starterFilesFor(starterActor, localBrowser.value, starterLocalAgentFor(machine), machine.humanish), options.localBrowser !== undefined, plan);
48
51
  await planRuntimeDirectories(preparedProjectRoot, cwd, plan);
49
52
  const gitignorePlan = await planGitignore(preparedProjectRoot, cwd);
50
53
  plan.changes.push(gitignorePlan.change);
@@ -88,47 +91,40 @@ export async function runInit(options) {
88
91
  // `humanish init` is a coding agent doing setup on someone's behalf. Without this, the agent
89
92
  // that arrives tomorrow finds a humanish/ directory and no idea what to do with it.
90
93
  //
91
- // Append-only and idempotent: an existing `AGENTS.md` is a file someone wrote, so humanish adds its
92
- // own section once and never rewrites theirs.
93
- async function planAgentsFile(preparedProjectRoot, cwd, plan) {
94
+ // An existing `AGENTS.md` is a file someone wrote. init appends its own section once, and on a
95
+ // rerun replaces only that section, between its two markers, so the commands match the installed
96
+ // humanish. Every byte outside the markers stays as it was.
97
+ async function planAgentsFile(preparedProjectRoot, cwd, plan, humanish) {
94
98
  const agentsPath = "AGENTS.md";
95
99
  const existingAgents = await readTextIfExists(preparedProjectRoot, agentsPath);
96
- const section = agentsSection();
97
- if (existingAgents === null) {
98
- plan.changes.push({
99
- path: agentsPath,
100
- action: "create",
101
- target: "source",
102
- reason: "how a coding agent runs humanish here",
103
- });
100
+ const section = agentsSection(humanish);
101
+ const write = (contents, action, reason) => {
102
+ plan.changes.push({ path: agentsPath, action, target: "source", reason });
104
103
  plan.writes.push({
105
104
  absolutePath: path.join(cwd, agentsPath),
106
105
  relativePath: agentsPath,
107
- contents: `# AGENTS.md\n${section}`,
106
+ contents,
108
107
  target: "source",
109
108
  });
109
+ };
110
+ const skip = (reason) => plan.changes.push({ path: agentsPath, action: "skip", target: "source", reason });
111
+ if (existingAgents === null) {
112
+ write(`# AGENTS.md\n${section}`, "create", "how a coding agent runs humanish here");
113
+ return;
110
114
  }
111
- else if (existingAgents.includes(AGENTS_SECTION_MARKER)) {
112
- plan.changes.push({
113
- path: agentsPath,
114
- action: "skip",
115
- target: "source",
116
- reason: "humanish section already present",
117
- });
115
+ const replaced = replaceAgentsSection(existingAgents, section);
116
+ if (replaced.kind === "absent") {
117
+ write(`${existingAgents.replace(/\s*$/, "")}\n${section}`, "update", "append how a coding agent runs humanish");
118
+ }
119
+ else if (replaced.kind === "unknown") {
120
+ skip("humanish section edited by hand");
121
+ plan.warnings.push(`Left the humanish section of ${agentsPath} unchanged: it has no end marker and does not match a version init wrote, so it may hold your edits. To get the current section, delete it from the "## humanish" heading to the end of the section and run init again.`);
122
+ }
123
+ else if (replaced.contents === existingAgents) {
124
+ skip("humanish section is current");
118
125
  }
119
126
  else {
120
- plan.changes.push({
121
- path: agentsPath,
122
- action: "update",
123
- target: "source",
124
- reason: "append how a coding agent runs humanish",
125
- });
126
- plan.writes.push({
127
- absolutePath: path.join(cwd, agentsPath),
128
- relativePath: agentsPath,
129
- contents: `${existingAgents.replace(/\s*$/, "")}\n${section}`,
130
- target: "source",
131
- });
127
+ write(replaced.contents, "update", "refresh the humanish section");
132
128
  }
133
129
  }
134
130
  /** Create each starter file that is missing; never overwrite one that exists. */
@@ -318,8 +314,9 @@ async function firstRunEnvironment(env, cwd) {
318
314
  localAgents: agents
319
315
  .filter((agent) => agent.authStatus === "authenticated")
320
316
  .map((agent) => ({ id: agent.id, label: agent.label })),
317
+ localBrowserHost: await probeLocalBrowserHost(),
321
318
  platform: process.platform,
322
- arch: process.arch,
319
+ humanish: humanishCommand(),
323
320
  };
324
321
  }
325
322
  function getMode(options) {
@@ -0,0 +1,11 @@
1
+ import type { LocalBrowserHost } from "../cli/first-run-path.js";
2
+ export interface LocalBrowserHostProbe {
3
+ platform: NodeJS.Platform;
4
+ arch: string;
5
+ env: NodeJS.ProcessEnv;
6
+ /** Resolves when `file` can be opened with `mode`; rejects otherwise. */
7
+ access: (file: string, mode: number) => Promise<void>;
8
+ /** The Mac's chip name, as `sysctl -n machdep.cpu.brand_string` prints it. */
9
+ macChip: () => Promise<string>;
10
+ }
11
+ export declare function probeLocalBrowserHost(probe?: LocalBrowserHostProbe): Promise<LocalBrowserHost>;
@@ -0,0 +1,42 @@
1
+ // init's quick read of whether this host can run a local browser study, so its next steps never
2
+ // send someone to a route the host cannot run. It reads devices, `PATH` and the Mac chip name only;
3
+ // it starts no container and no VM. `doctor --study local-browser` runs the full check, including
4
+ // whether Docker is rootful and local.
5
+ import { execFile } from "node:child_process";
6
+ import { constants } from "node:fs";
7
+ import { access } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ const exec = promisify(execFile);
11
+ const defaultProbe = () => ({
12
+ platform: process.platform,
13
+ arch: process.arch,
14
+ env: process.env,
15
+ access,
16
+ macChip: async () => (await exec("sysctl", ["-n", "machdep.cpu.brand_string"], { timeout: 5_000 })).stdout,
17
+ });
18
+ export async function probeLocalBrowserHost(probe = defaultProbe()) {
19
+ const can = async (file, mode) => probe.access(file, mode).then(() => true, () => false);
20
+ if (probe.platform === "linux" && probe.arch === "x64") {
21
+ // The same device checks doctor makes. The rootful Docker daemon opens these devices, so this
22
+ // user needs no access to them.
23
+ if (!(await can("/dev/kvm", constants.F_OK)))
24
+ return { ok: false, reason: "/dev/kvm is missing" };
25
+ if (!(await can("/dev/net/tun", constants.F_OK)))
26
+ return { ok: false, reason: "/dev/net/tun is missing" };
27
+ const dirs = (probe.env.PATH ?? "").split(path.delimiter).filter(Boolean);
28
+ for (const dir of dirs)
29
+ if (await can(path.join(dir, "docker"), constants.X_OK))
30
+ return { ok: true };
31
+ return { ok: false, reason: "the docker command is not on the search path" };
32
+ }
33
+ if (probe.platform === "darwin" && probe.arch === "arm64") {
34
+ // The local route needs nested virtualization, which Apple added with the M3.
35
+ const chip = await probe.macChip().catch(() => "");
36
+ const generation = /^Apple M(\d+)/.exec(chip.trim());
37
+ if (generation !== null && Number(generation[1]) >= 3)
38
+ return { ok: true };
39
+ return { ok: false, reason: "they need an M3 or newer Mac" };
40
+ }
41
+ return { ok: false, reason: "they need Linux x64 or an M3-or-newer Mac" };
42
+ }
@@ -2,6 +2,7 @@ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
2
2
  import { isReasoningEffort, reasoningEffortNames } from "../../actors/reasoning-effort.js";
3
3
  import { isMaxOutputTokens } from "../../actors/output-token-limit.js";
4
4
  import { isHttpUrl } from "./subject.js";
5
+ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js";
5
6
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
6
7
  import { invalid, posInt, str } from "./values.js";
7
8
  import { isRecord } from "../../run/type-guards.js";
@@ -265,13 +266,20 @@ function parseParticipantEntries(raw, actorIndex) {
265
266
  if (!isHttpUrl(target)) {
266
267
  return invalid(`actors[${actorIndex}].lanes[${entryIndex}].target must be an absolute http(s) URL.`);
267
268
  }
269
+ const credential = urlCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].target`, target);
270
+ if (credential)
271
+ return invalid(credential);
268
272
  parsedEntry.target = target;
269
273
  }
270
274
  // `entry` is shape-captured here; the same-origin-with-serve.url check needs serve context, so
271
275
  // it runs in sharedWorldValidationReason (where the route + serve.url are known).
272
276
  const entryPath = str(entry.entry);
273
- if (entryPath !== undefined)
277
+ if (entryPath !== undefined) {
278
+ const credential = entryCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].entry`, entryPath);
279
+ if (credential)
280
+ return invalid(credential);
274
281
  parsedEntry.entry = entryPath;
282
+ }
275
283
  // `host` marks the designated host on the external-public shared-world route; the
276
284
  // exactly-one-host check runs in externalPublicSharedWorldValidationReason (route context).
277
285
  if (entry.host !== undefined) {