humanish 0.80.0 → 0.82.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 (69) hide show
  1. package/README.md +121 -615
  2. package/dist/actor-contract.d.ts +30 -0
  3. package/dist/actor-contract.js.map +1 -1
  4. package/dist/chrome-cdp-probe.js +13 -0
  5. package/dist/chrome-cdp-probe.js.map +1 -1
  6. package/dist/computer-use.d.ts +13 -3
  7. package/dist/computer-use.js +179 -15
  8. package/dist/computer-use.js.map +1 -1
  9. package/dist/cua-actor-lab.d.ts +20 -8
  10. package/dist/cua-actor-lab.js +154 -49
  11. package/dist/cua-actor-lab.js.map +1 -1
  12. package/dist/e2b-desktop-executor.d.ts +9 -1
  13. package/dist/e2b-desktop-executor.js +67 -4
  14. package/dist/e2b-desktop-executor.js.map +1 -1
  15. package/dist/e2b-desktop-launch.d.ts +36 -1
  16. package/dist/e2b-desktop-launch.js +88 -1
  17. package/dist/e2b-desktop-launch.js.map +1 -1
  18. package/dist/e2b-desktop-resources.d.ts +11 -0
  19. package/dist/e2b-desktop-resources.js +33 -0
  20. package/dist/e2b-desktop-resources.js.map +1 -0
  21. package/dist/e2b-desktop-screenshot-cleanup.d.ts +15 -0
  22. package/dist/e2b-desktop-screenshot-cleanup.js +67 -0
  23. package/dist/e2b-desktop-screenshot-cleanup.js.map +1 -0
  24. package/dist/e2b-terminal-lab.d.ts +5 -2
  25. package/dist/e2b-terminal-lab.js +238 -86
  26. package/dist/e2b-terminal-lab.js.map +1 -1
  27. package/dist/first-run-path.js +2 -2
  28. package/dist/first-run-path.js.map +1 -1
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/lab-config.d.ts +15 -14
  33. package/dist/lab-config.js +11 -2
  34. package/dist/lab-config.js.map +1 -1
  35. package/dist/observer-data.d.ts +2 -0
  36. package/dist/observer-data.js +10 -1
  37. package/dist/observer-data.js.map +1 -1
  38. package/dist/openai-responses-cu.js +76 -49
  39. package/dist/openai-responses-cu.js.map +1 -1
  40. package/dist/pricing.d.ts +19 -1
  41. package/dist/pricing.js +43 -10
  42. package/dist/pricing.js.map +1 -1
  43. package/dist/program.js +17 -8
  44. package/dist/program.js.map +1 -1
  45. package/dist/redaction.js +9 -2
  46. package/dist/redaction.js.map +1 -1
  47. package/dist/run.d.ts +14 -2
  48. package/dist/run.js.map +1 -1
  49. package/dist/terminal-node-bootstrap.d.ts +4 -0
  50. package/dist/terminal-node-bootstrap.js +58 -0
  51. package/dist/terminal-node-bootstrap.js.map +1 -0
  52. package/dist/terminal-runtime-auth.d.ts +13 -0
  53. package/dist/terminal-runtime-auth.js +24 -0
  54. package/dist/terminal-runtime-auth.js.map +1 -0
  55. package/dist/terminal-runtime.d.ts +16 -0
  56. package/dist/terminal-runtime.js +42 -0
  57. package/dist/terminal-runtime.js.map +1 -0
  58. package/dist/terminal-token-usage.d.ts +2 -1
  59. package/dist/terminal-token-usage.js +3 -2
  60. package/dist/terminal-token-usage.js.map +1 -1
  61. package/docs/architecture/actor-contract.md +22 -0
  62. package/docs/architecture/state-driven-executor.md +10 -1
  63. package/docs/architecture/terminal-product-lane.md +142 -6
  64. package/docs/contracts/feedback.md +14 -0
  65. package/docs/contracts/run-bundle.md +5 -1
  66. package/docs/contracts/schemas.md +17 -8
  67. package/docs/goals/current.md +61 -13
  68. package/docs/ramp/README.md +1 -1
  69. package/package.json +4 -2
@@ -56,7 +56,8 @@ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, pre
56
56
  import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js";
57
57
  import { createLocalTreeArchive } from "./source-archive.js";
58
58
  import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA, aggregateTaskFunnels, formatParticipantOutcomes, formatStudyTaskFunnel, tallyParticipantOutcomes } from "./run.js";
59
- import { estimateActorCost, estimateDesktopCost, MODEL_RATES, round6 } from "./pricing.js";
59
+ import { estimateActorCost, estimateDesktopCost, estimateAllocatedDesktopCost, MODEL_RATES, round6 } from "./pricing.js";
60
+ import { observeDesktopResources } from "./e2b-desktop-resources.js";
60
61
  export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
61
62
  // The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
62
63
  // each its own world (clone/serve + subject.state per lane). Shared-world is layer 7 (#164).
@@ -1063,7 +1064,11 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
1063
1064
  throw new Error(`mobile emulation could not be applied (${announced.unavailable}); applied before failing: ${(announced.applied ?? []).join(", ") || "nothing"}`);
1064
1065
  }
1065
1066
  const applied = announced;
1066
- const warnings = [];
1067
+ // Viewport/touch read-back proves context settings, not gesture equivalence. Two hosted
1068
+ // replicas and a native-X conversion-toggle control reproduced reset click counts (#676).
1069
+ const warnings = request.touch
1070
+ ? ["Mobile emulation uses desktop pointer-to-touch conversion, which can differ for repeated taps. Confirm gesture failures with direct or native touch input before attributing them to the app."]
1071
+ : [];
1067
1072
  // The reload inside the applier takes a moment; the read-back is retried until the page reports
1068
1073
  // the requested viewport and user agent, so a slow page does not read as "no proof".
1069
1074
  let readBack = await read();
@@ -1335,14 +1340,56 @@ const REPORTED_DEFECT_LANGUAGE = /\b(defects?|bugs?|accessibilit(y|ies)|inaccess
1335
1340
  // counted as reported friction and became a feedback candidate whose "actual" was a sentence
1336
1341
  // reporting no problem. Only the report-shaped adjectives are negatable here: "no visible focus",
1337
1342
  // "not keyboard-accessible" and "did nothing" are defects and stay.
1338
- const NEGATED_REPORT_LANGUAGE = /\b(?:nothing|no|not|never|without|none)\s+(?:was\s+|were\s+|felt\s+|seemed\s+|really\s+|particularly\s+|especially\s+|major\s+|real\s+|obvious\s+|noticeable\s+)*(?:confus(?:ed|ing|ion)|unclear|unexpected(?:ly)?|hesitat(?:ed|ion|ions)|surpris(?:ed|ing|es)|defects?|bugs?|overlap(?:ped|ping|s)?|truncat(?:ed|es|ion)|hard to (?:find|tell|see|read|reach))\b/g;
1343
+ const NEGATED_REPORT_ITEM = String.raw `(?:confus(?:ed|ing|ion)|unclear(?:\s+error\s+output)?|unexpected(?:ly)?|hesitat(?:ed|ion|ions)|surpris(?:ed|ing|es)|defects?|bugs?|overlap(?:ped|ping|s)?|truncat(?:ed|es|ion)|hard to (?:find|tell|see|read|reach)|blockers?|blocking issues?|errors?(?:\s+output)?|failures?|problems?|issues?)`;
1344
+ const NEGATED_REPORT_QUALIFIERS = String.raw `(?:(?:really|particularly|especially|major|minor|real|actual|remaining|functional|obvious|noticeable|significant|any|a|an)\s+)*`;
1345
+ const NEGATED_REPORT_MODIFIERS = String.raw `(?:(?:was|were|felt|seemed|really|particularly|especially|major|minor|real|actual|remaining|functional|obvious|noticeable|significant|any|a|an|encounter(?:ed)?|experience(?:d)?|notice(?:d)?|observe(?:d)?|feel|find|found|have|had)\s+)*`;
1346
+ const NEGATED_REPORT_LANGUAGE = new RegExp(String.raw `\b(?:nothing|no|not|never|without|none|(?:did|do|does|was|were|has|have|had)n['’]t)\s+${NEGATED_REPORT_MODIFIERS}${NEGATED_REPORT_ITEM}\b`
1347
+ // Negation scopes over a coordinated report list, not the rest of the sentence. In
1348
+ // particular, leave "but the label was confusing" and "and Save did nothing" intact.
1349
+ + String.raw `(?:\s*,?\s+(?:or|nor|and)\s+${NEGATED_REPORT_QUALIFIERS}${NEGATED_REPORT_ITEM}\b)*`
1350
+ // Keep the predicate inside its negation: "No errors blocked me" reports no blocker.
1351
+ + String.raw `(?:\s+(?:blocked|stopped|prevented)\s+(?:me|us|it)\b)?`, "g");
1339
1352
  function stripNegatedReportLanguage(text) {
1340
- return text.replace(NEGATED_REPORT_LANGUAGE, " ");
1353
+ // "Not without hesitation" reports hesitation; do not let the inner "without" erase it.
1354
+ return text.replace(/\b(?:not|never)\s+without\b/g, "with").replace(NEGATED_REPORT_LANGUAGE, " ");
1341
1355
  }
1342
1356
  function completionReasonContradictsGoal(reason) {
1343
- const text = stripQuotedSpans(stripNegatedReportLanguage(stripNegatedNonBlockerPhrases(reason.toLowerCase())));
1357
+ // Preserve the full negated list before the older blocker-specific rules remove its first
1358
+ // noun ("no issues or hesitation"). Matching reports first also avoids their broad encounter
1359
+ // clause rule swallowing a genuine subsequent observation.
1360
+ const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(stripNegatedReportLanguage(stripCodeExamples(reason).toLowerCase())));
1344
1361
  return hasBlockerLanguage(text) || REPORTED_DEFECT_LANGUAGE.test(text);
1345
1362
  }
1363
+ /** Code/documentation excerpts are quoted material, not participant observations. */
1364
+ function stripCodeExamples(text) {
1365
+ return text
1366
+ // Include an unterminated fence: copied text is not promoted just because its closing fence
1367
+ // was omitted. Match the same marker so backticks inside a tilde fence cannot end it early.
1368
+ .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|(?![\s\S]))/gm, " ")
1369
+ .replace(/(`+)[^`\n]*\1/g, " ");
1370
+ }
1371
+ /** Interim messages also contain plans and hypotheses. Admit observed-report clauses only;
1372
+ * the established closing-report scan remains separate. This is a conservative text heuristic,
1373
+ * not an assertion that every mention of a defect is evidence that one happened. */
1374
+ function interimMessageReportsFriction(message) {
1375
+ const prose = stripQuotedSpans(stripCodeExamples(message).toLowerCase());
1376
+ const clauses = prose.split(/(?<=[.!?])\s+|[;\n]+|,\s*(?:but|so|however|yet)\s+|\s+so\s+(?=i\b|we\b)/);
1377
+ return clauses.some((clause) => {
1378
+ // A condition, question, intention, or conjecture does not assert an observed result.
1379
+ // Clause splitting above keeps "Save did nothing, so I will try Enter" observable.
1380
+ if (/\?|\b(?:if|unless|whether|maybe|perhaps|suppose|hypothetically|might|may|would|should)\b|\bcould\b(?!\s+not\b)/.test(clause)
1381
+ || /\b(?:i|we)(?:['’]ll|\s+(?:will|plan|intend|want|hope|suspect|wonder))\b|\bgoing to\b|\blet['’]s\b/.test(clause)
1382
+ || /\b(?:task|goal|mission|objective|plan)\s+(?:(?:is|was)\s+)?to\b|^\s*(?:check|test|look|checking|testing)\b/.test(clause))
1383
+ return false;
1384
+ // A topic is not a defect ("the accessibility guide is open", "shows error-handling docs").
1385
+ // Actual friction in those surfaces still qualifies: "the error guide was confusing".
1386
+ const observation = clause
1387
+ .replace(/\baccessibilit(?:y|ies)\b/g, " ")
1388
+ .replace(/\berror[- ]handling\b|\berror\s+(?:documentation|docs?|guides?|reference|examples?)\b/g, " ");
1389
+ const assertsObservation = /\b(?:is|are|was|were|has|had|did|does|shows?|showed|seems?|seemed|looks?|looked|found|noticed|saw|hit|encountered|felt|got|failed|returned|cannot|can['’]?t|unable|could not)\b|\b(?:overlap(?:ped|ping|s)?|truncat(?:ed|es)|cut off|nothing happened|no (?:visible )?focus)\b/.test(observation);
1390
+ return assertsObservation && completionReasonContradictsGoal(observation);
1391
+ });
1392
+ }
1346
1393
  /** The verdict scan (strict): like the friction scan, but resolved-arc segments are stripped
1347
1394
  * first — failure narration the participant itself reports as overcome is friction on the
1348
1395
  * road, not a blocker at the destination (#453). */
@@ -1438,21 +1485,39 @@ export function resolveSelfReportedBlocker(session) {
1438
1485
  : undefined;
1439
1486
  }
1440
1487
  /**
1441
- * The friction read of the same narrative (#453): everything the verdict scan counts PLUS
1442
- * resolved arcs — a participant who hit a wall, got past it, and said so has reported friction
1443
- * worth a tally count and a feedback candidate, without costing the lane its pass. Same
1444
- * quoted-copy and stopWhen discipline as the verdict resolver. Exported for testing.
1488
+ * Friction is independent of how a completed session ended (#657). Read the participant's
1489
+ * redacted messages, including earlier reports, rather than the harness-owned reason that
1490
+ * stopWhen/dwell writes. Reasoning, observations, and notices are not participant reports.
1491
+ * Resolved arcs still count (#453); quoted copy and negated reports still do not. This read
1492
+ * never changes the verdict. Exported for testing.
1445
1493
  */
1446
1494
  export function resolveSelfReportedFriction(session) {
1495
+ if (session?.completionReason !== "goal_satisfied")
1496
+ return undefined;
1447
1497
  // Friction stays a read of the narrative even when the outcome was declared: a participant who
1448
1498
  // reached the goal and described what was hard on the way has reported friction.
1449
- if (session?.trace.declaredOutcome === "blocked" && session.completionReason === "goal_satisfied")
1499
+ if (session.trace.declaredOutcome === "blocked")
1450
1500
  return session.reason;
1451
- return session?.completionReason === "goal_satisfied"
1452
- && completionReasonContradictsGoal(session.reason)
1453
- && !traceHasStopWhenMatch(session)
1454
- ? session.reason
1455
- : undefined;
1501
+ const messages = session.trace.items
1502
+ .filter((item) => item.kind === "message" && item.id !== session.trace.debrief?.messageId)
1503
+ .map((item) => item.text?.trim() ?? "")
1504
+ .filter((text) => text.length > 0);
1505
+ // A custom session may keep its closing report only in reason even when earlier messages exist.
1506
+ // Structured stop/dwell reasons are controller text and never enter this closing-report path.
1507
+ const closingReport = traceHasStopWhenMatch(session) ? undefined : session.reason.trim();
1508
+ // One candidate per participant, with exact repeats removed (the closing report often repeats
1509
+ // a prior turn). Earlier turns require observed-report clauses, not arbitrary defect mentions.
1510
+ const typedReports = session.trace.debrief?.status === "completed"
1511
+ ? session.trace.debrief.report?.frictionReports ?? [] : [];
1512
+ const reports = [...new Set([...typedReports, ...messages.filter((message) => message === closingReport
1513
+ ? completionReasonContradictsGoal(message)
1514
+ : interimMessageReportsFriction(message))])];
1515
+ if (closingReport && completionReasonContradictsGoal(closingReport) && !reports.includes(closingReport)) {
1516
+ reports.push(closingReport);
1517
+ }
1518
+ if (reports.length > 0)
1519
+ return reports.join("\n\n");
1520
+ return undefined;
1456
1521
  }
1457
1522
  /**
1458
1523
  * Run ONE E2B desktop lane end-to-end: create the sandbox (per-lane metadata + the lane's device
@@ -1540,9 +1605,11 @@ export async function runCuaLane(spec, deps) {
1540
1605
  let sandboxId;
1541
1606
  // Host-side E2B desktop billed-span endpoints, measured via the injected clock. Captured right
1542
1607
  // after create() succeeds and again in the finally after teardown resolves (both the killed and
1543
- // kept-for-debug paths), so the desktop-minute cost estimate reflects the honest lifetime.
1608
+ // kept-for-debug paths). This measured span excludes allocation before the acquired handle;
1609
+ // a kept/unconfirmed allocation gets an extra unknown lifetime cost line.
1544
1610
  let sandboxCreatedAtMs;
1545
1611
  let sandboxTornDownAtMs;
1612
+ let desktopResources;
1546
1613
  let killed = false;
1547
1614
  let streamUrl;
1548
1615
  let subjectCommit;
@@ -1607,13 +1674,11 @@ export async function runCuaLane(spec, deps) {
1607
1674
  dpi: 96,
1608
1675
  lifecycle: { onTimeout: "kill" }
1609
1676
  }, config.execution?.desktop?.template, {
1610
- // One retry on a transient provider error (a sandbox whose envd was not routable yet, an
1611
- // API reply without a body). The failed attempt may have allocated a sandbox this process
1612
- // never learned the id of; its own timeoutMs is what reclaims it, so the warning says so.
1677
+ // The default loader reclaims an acquired handle before retrying failed desktop startup.
1678
+ // Its error names the cleanup outcome; pre-construction allocation failures remain unowned.
1613
1679
  onRetry: (reason) => {
1614
1680
  const named = redactText(deps.scrubKnownValues(reason));
1615
- warnings.push(`Sandbox create for lane ${spec.laneId} retried once after a transient provider error (${named}); ` +
1616
- `a sandbox the first attempt may have allocated is not known to this run and expires on the provider's ${Math.round(deps.perLaneSandboxMs / 60_000)}-minute timeout.`);
1681
+ warnings.push(`Sandbox create for lane ${spec.laneId} retried once after a transient provider error (${named}).`);
1617
1682
  onSubjectPhase({ at: new Date(deps.now()).toISOString(), type: "cua-lab.sandbox.create.retry", message: `sandbox create retried once (${named})` });
1618
1683
  }
1619
1684
  });
@@ -1623,6 +1688,10 @@ export async function runCuaLane(spec, deps) {
1623
1688
  await appendSandboxReceipt(deps.artifactRoot, { at: new Date(deps.now()).toISOString(), laneId: spec.laneId, sandboxId, timeoutMs: deps.perLaneSandboxMs });
1624
1689
  // The billed span starts the instant the sandbox exists.
1625
1690
  sandboxCreatedAtMs = deps.now();
1691
+ desktopResources = await observeDesktopResources(desktop);
1692
+ if ("reason" in desktopResources) {
1693
+ warnings.push(`Desktop resource size unavailable (${desktopResources.reason}); compute cost remains unpriced.`);
1694
+ }
1626
1695
  if (deps.hooks.prepareDesktop) {
1627
1696
  await deps.hooks.prepareDesktop(desktop, { laneId: spec.laneId, laneIndex: spec.laneIndex, laneCount: deps.laneCount });
1628
1697
  }
@@ -2137,8 +2206,8 @@ export async function runCuaLane(spec, deps) {
2137
2206
  else {
2138
2207
  warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox.");
2139
2208
  }
2140
- // Close the billed span for BOTH the killed and kept-for-debug paths (a kept sandbox is
2141
- // still billed until its server-side timeout, so the honest span ends here either way).
2209
+ // Close the observed span. A kept or unconfirmed sandbox can still accrue compute cost;
2210
+ // the summary records that remaining lifetime as unknown instead of calling this complete.
2142
2211
  sandboxTornDownAtMs = deps.now();
2143
2212
  // The lane's live stream is now a dead page whichever teardown path ran (killed, kept, or
2144
2213
  // kill-failed-awaiting-TTL) — tell the watch overlay so the tile falls back to recorded
@@ -2191,6 +2260,7 @@ export async function runCuaLane(spec, deps) {
2191
2260
  ...(sessionError === undefined ? {} : { sessionError }),
2192
2261
  ...(sandboxId === undefined ? {} : { sandboxId }),
2193
2262
  ...(desktopDurationMs === undefined ? {} : { desktopDurationMs }),
2263
+ ...(desktopResources === undefined ? {} : { desktopResources }),
2194
2264
  killed,
2195
2265
  streamUrlPresent: streamUrl !== undefined,
2196
2266
  screenshots,
@@ -3446,6 +3516,12 @@ function buildSingleLaneBundle(args) {
3446
3516
  ...(desktopSpanToMinutes(outcome?.desktopDurationMs) === undefined
3447
3517
  ? {}
3448
3518
  : { desktopMinutes: desktopSpanToMinutes(outcome?.desktopDurationMs) }),
3519
+ ...(outcome?.sandboxId === undefined ? {} : { desktopUsage: {
3520
+ laneId: spec.laneId,
3521
+ minutes: desktopSpanToMinutes(outcome.desktopDurationMs),
3522
+ observation: outcome.desktopResources,
3523
+ lifetimeComplete: outcome.killed
3524
+ } }),
3449
3525
  phaseEvents: outcome?.phaseRecords ?? []
3450
3526
  });
3451
3527
  }
@@ -3825,20 +3901,6 @@ function describeSubjectState(state, dryRun) {
3825
3901
  function tailOf(log) {
3826
3902
  return redactedTail(log, ERROR_TAIL_CHARS);
3827
3903
  }
3828
- /**
3829
- * Project a computer-use session into a humanish.run-bundle.v1. The load-bearing line is
3830
- * `stream.actor = session.trace` — the provider-neutral ActorTrace seam the Observer renders.
3831
- * Exported for the bundle-builder tests.
3832
- */
3833
- /**
3834
- * Assemble the run-level cost ESTIMATE from each lane's persisted per-actor estimate
3835
- * (trace.estimatedCost, set at the lab boundary) plus the aggregated E2B desktop-minute span.
3836
- * Returns undefined (cost OMITTED) when nothing was priceable AND no sandbox ran — a pure dry-run
3837
- * or an in-process lane (no trace.estimatedCost, no desktop) stays byte-stable with no cost block.
3838
- * The null-discipline mirrors the terminal ledger: a present-but-unpriceable line is null + a
3839
- * reason and contributes NOTHING to estimatedTotalUsd (never coerced to 0); an all-null summary
3840
- * has a null total. Every non-null figure carries its ratesAsOf date + source (invariant 6).
3841
- */
3842
3904
  export function buildCuaCostSummary(args) {
3843
3905
  const breakdown = [];
3844
3906
  let sumInput = 0;
@@ -3849,6 +3911,18 @@ export function buildCuaCostSummary(args) {
3849
3911
  sumInput += usage.input ?? 0;
3850
3912
  sumOutput += usage.output ?? 0;
3851
3913
  }
3914
+ // An attempted closing request can fail after provider work without reporting usage.
3915
+ // Keep the known interaction estimate and make the additional unknown explicit.
3916
+ if (lane.trace.debrief?.usageReported === false) {
3917
+ breakdown.push({
3918
+ kind: "model-tokens",
3919
+ ...(lane.laneId === undefined ? {} : { laneId: lane.laneId }),
3920
+ ...(lane.trace.providerVersion === undefined ? {} : { modelId: lane.trace.providerVersion }),
3921
+ estimatedCostUsd: null,
3922
+ reason: "closing_usage_unreported",
3923
+ ratesAsOf: null
3924
+ });
3925
+ }
3852
3926
  const est = lane.trace.estimatedCost;
3853
3927
  if (!est) {
3854
3928
  continue;
@@ -3864,7 +3938,31 @@ export function buildCuaCostSummary(args) {
3864
3938
  ...(est.placeholder ? { placeholder: true } : {})
3865
3939
  });
3866
3940
  }
3867
- if (args.desktopMinutes !== undefined) {
3941
+ for (const usage of args.desktops ?? []) {
3942
+ const observation = usage.observation;
3943
+ const resources = observation && "resources" in observation ? observation.resources : undefined;
3944
+ const estimate = estimateAllocatedDesktopCost(usage.minutes, resources);
3945
+ breakdown.push({
3946
+ kind: "desktop-minutes",
3947
+ ...(usage.laneId === undefined ? {} : { laneId: usage.laneId }),
3948
+ estimatedCostUsd: estimate.estimatedCostUsd,
3949
+ ...(estimate.reason === undefined ? {} : { reason: estimate.reason }),
3950
+ ratesAsOf: estimate.ratesAsOf,
3951
+ ...(estimate.source === undefined ? {} : { source: estimate.source }),
3952
+ desktop: {
3953
+ minutes: estimate.minutes,
3954
+ durationBasis: "host-acquired-to-cleanup",
3955
+ ...(resources === undefined ? {} : { resources, resourceSource: "e2b.getInfo" }),
3956
+ ...(observation && "reason" in observation ? { resourceUnavailableReason: observation.reason } : {}),
3957
+ ...(estimate.usdPerSecond === undefined ? {} : { usdPerSecond: estimate.usdPerSecond })
3958
+ }
3959
+ });
3960
+ if (!usage.lifetimeComplete) {
3961
+ breakdown.push({ kind: "desktop-minutes", ...(usage.laneId === undefined ? {} : { laneId: usage.laneId }),
3962
+ estimatedCostUsd: null, reason: "desktop_lifetime_incomplete", ratesAsOf: null });
3963
+ }
3964
+ }
3965
+ if (args.desktops === undefined && args.desktopMinutes !== undefined) {
3868
3966
  const desktop = estimateDesktopCost(args.desktopMinutes);
3869
3967
  breakdown.push({
3870
3968
  kind: "desktop-minutes",
@@ -3900,9 +3998,12 @@ export function buildCuaCostSummary(args) {
3900
3998
  }
3901
3999
  }
3902
4000
  const estimatedTotalUsd = anyKnown ? round6(knownSum) : null;
3903
- const note = estimatedTotalUsd === null
4001
+ const estimateNote = estimatedTotalUsd === null
3904
4002
  ? `No priced spend lines this run — every cost line is DECLARED ABSENT (unknown rate / no usage / no duration); nothing is guessed. Add a rate to src/pricing.ts to estimate this model.`
3905
4003
  : `Estimated ${estimatedTotalUsd} USD total${anyNull ? " (LOWER BOUND — some lines unmeasured/unpriced)" : ""}${placeholder ? "; includes PLACEHOLDER rate(s) — confirm before trusting the magnitude" : ""}. Every figure is an ESTIMATE (rates as of ${minRatesAsOf} — the OLDEST contributing rate, since an aggregate is only as fresh as its stalest input), a rate-table multiply, NOT an authoritative provider charge.`;
4004
+ const note = estimateNote + ((args.desktops?.length ?? 0) > 0
4005
+ ? " Desktop compute uses observed CPU/RAM and a host-acquired-to-cleanup span; pre-handle startup, plan fees, credits, and negotiated pricing are excluded."
4006
+ : "");
3906
4007
  return {
3907
4008
  schema: "humanish.run-cost-summary.v1",
3908
4009
  currency: "usd",
@@ -3912,7 +4013,9 @@ export function buildCuaCostSummary(args) {
3912
4013
  placeholder,
3913
4014
  breakdown,
3914
4015
  tokenUsage: { input: sumInput, output: sumOutput, total: sumInput + sumOutput },
3915
- desktopMinutes: args.desktopMinutes ?? null,
4016
+ desktopMinutes: args.desktops === undefined ? args.desktopMinutes ?? null
4017
+ : args.desktops.some(usage => usage.minutes !== undefined)
4018
+ ? round6(args.desktops.reduce((sum, usage) => sum + (usage.minutes ?? 0), 0)) : null,
3916
4019
  note
3917
4020
  };
3918
4021
  }
@@ -3929,8 +4032,8 @@ function desktopSpanToMinutes(desktopDurationMs) {
3929
4032
  * trying. A clean pass files nothing here — feedback exists to carry findings, and a run without
3930
4033
  * any falls back to an honest live summary in the draft layer instead of a template.
3931
4034
  *
3932
- * Everything quoted is already scrub+redacted — `session.reason` passes through redactNarration in
3933
- * the loop before it ever lands on a trace — and passes redactText again here as defense-in-depth.
4035
+ * Everything quoted is already scrub+redacted — participant messages and `session.reason` pass
4036
+ * through redactNarration in the loop — and passes redactText again here as defense-in-depth.
3934
4037
  */
3935
4038
  export function participantFeedbackCandidates(args) {
3936
4039
  const candidates = [];
@@ -3999,7 +4102,8 @@ export function buildCuaBundle(args) {
3999
4102
  // Run-level cost ESTIMATE (advisory; omitted when nothing was priced and no sandbox ran).
4000
4103
  const cost = buildCuaCostSummary({
4001
4104
  lanes: args.session ? [{ ...(args.laneId === undefined ? {} : { laneId: args.laneId }), trace: args.session.trace }] : [],
4002
- desktopMinutes: args.desktopMinutes
4105
+ desktopMinutes: args.desktopMinutes,
4106
+ ...(args.desktopUsage === undefined ? {} : { desktops: [args.desktopUsage] })
4003
4107
  });
4004
4108
  const status = args.inProgress === true
4005
4109
  ? "running"
@@ -4713,16 +4817,17 @@ export function buildCuaFanoutBundle(args) {
4713
4817
  laneId: outcome.spec.laneId
4714
4818
  }));
4715
4819
  // Run-level cost ESTIMATE: one model-token line per lane that ran a session (from its persisted
4716
- // trace.estimatedCost) + one aggregate desktop-minutes line summing each lane's OWN sandbox span
4717
- // (per-lane worlds => no shared provisioning to double-count). Omitted on a pure dry-run.
4820
+ // trace.estimatedCost) + a desktop line per owned allocation, priced at its observed resources.
4821
+ // Per-lane worlds have no shared provisioning to double-count. Omitted on a pure dry-run.
4718
4822
  const costLanes = specs
4719
4823
  .map((spec, index) => ({ laneId: spec.laneId, outcome: outcomes?.[index] }))
4720
4824
  .filter((entry) => entry.outcome?.session !== undefined)
4721
4825
  .map((entry) => ({ laneId: entry.laneId, trace: entry.outcome.session.trace }));
4722
- const desktopMinutesTotal = (outcomes ?? []).some((outcome) => outcome.desktopDurationMs !== undefined)
4723
- ? (outcomes ?? []).reduce((sum, outcome) => sum + (outcome.desktopDurationMs ?? 0), 0) / 60_000
4724
- : undefined;
4725
- const cost = buildCuaCostSummary({ lanes: costLanes, desktopMinutes: desktopMinutesTotal });
4826
+ const desktops = (outcomes ?? []).filter(outcome => outcome.sandboxId !== undefined).map(outcome => ({
4827
+ laneId: outcome.spec.laneId, minutes: desktopSpanToMinutes(outcome.desktopDurationMs),
4828
+ observation: outcome.desktopResources, lifetimeComplete: outcome.killed
4829
+ }));
4830
+ const cost = buildCuaCostSummary({ lanes: costLanes, desktops });
4726
4831
  return {
4727
4832
  schema: RUN_BUNDLE_SCHEMA,
4728
4833
  runId: args.runId,