humanish 0.140.0 → 0.142.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 (155) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +2 -0
  4. package/dist/actors/codex/restricted-participant.js +1 -1
  5. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  6. package/dist/actors/computer-use/external-handoff.js +83 -0
  7. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  8. package/dist/actors/computer-use/loop/observation.js +17 -1
  9. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  10. package/dist/analysis/admission.d.ts +8 -0
  11. package/dist/analysis/admission.js +15 -4
  12. package/dist/analysis/analysis-limits.d.ts +6 -0
  13. package/dist/analysis/analysis-limits.js +6 -0
  14. package/dist/analysis/analysis-lock.d.ts +8 -0
  15. package/dist/analysis/analysis-lock.js +129 -0
  16. package/dist/analysis/automatic-completion.js +7 -0
  17. package/dist/analysis/automatic-config.d.ts +5 -2
  18. package/dist/analysis/automatic-config.js +23 -8
  19. package/dist/analysis/automatic.js +13 -7
  20. package/dist/analysis/execute.d.ts +17 -6
  21. package/dist/analysis/execute.js +37 -19
  22. package/dist/analysis/gemini-wire.d.ts +8 -0
  23. package/dist/analysis/gemini-wire.js +165 -0
  24. package/dist/analysis/image-tokens.d.ts +4 -2
  25. package/dist/analysis/image-tokens.js +24 -3
  26. package/dist/analysis/job.d.ts +2 -0
  27. package/dist/analysis/job.js +7 -2
  28. package/dist/analysis/openai-wire.d.ts +5 -0
  29. package/dist/analysis/openai-wire.js +101 -0
  30. package/dist/analysis/provider.d.ts +15 -7
  31. package/dist/analysis/provider.js +21 -101
  32. package/dist/analysis/recording-ended.d.ts +16 -0
  33. package/dist/analysis/recording-ended.js +29 -0
  34. package/dist/analysis/responses.d.ts +3 -0
  35. package/dist/analysis/responses.js +19 -0
  36. package/dist/analysis/service.d.ts +1 -3
  37. package/dist/analysis/service.js +34 -53
  38. package/dist/analysis/source.d.ts +1 -1
  39. package/dist/analysis/types.d.ts +8 -2
  40. package/dist/analysis/validation.d.ts +1 -0
  41. package/dist/analysis/validation.js +13 -12
  42. package/dist/browser-control/protocol.d.ts +8 -0
  43. package/dist/browser-control/protocol.js +21 -0
  44. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  45. package/dist/cli/commands/analysis-signals.js +28 -6
  46. package/dist/cli/commands/analyze.js +9 -7
  47. package/dist/cli/commands/observe.js +26 -1
  48. package/dist/cli/commands/run-command.d.ts +1 -1
  49. package/dist/cli/commands/run-command.js +12 -0
  50. package/dist/cli/commands/runs.js +8 -2
  51. package/dist/cli/commands/study-remote.d.ts +13 -0
  52. package/dist/cli/commands/study-remote.js +125 -0
  53. package/dist/cli/commands/study-route-computer-use.js +28 -4
  54. package/dist/cli/commands/study-route-scripted.js +3 -2
  55. package/dist/cli/commands/study-route-shared-world.js +3 -2
  56. package/dist/cli/commands/study-route-terminal.js +3 -2
  57. package/dist/cli/commands/study-run.js +34 -0
  58. package/dist/cli/commands/watch.js +5 -0
  59. package/dist/cli/doctor.js +7 -2
  60. package/dist/cli/findings.d.ts +30 -0
  61. package/dist/cli/findings.js +114 -15
  62. package/dist/cli/io.d.ts +8 -0
  63. package/dist/cli/io.js +3 -1
  64. package/dist/evidence/browser-hygiene.d.ts +12 -1
  65. package/dist/evidence/browser-hygiene.js +45 -2
  66. package/dist/feedback/export-bundle.js +1 -1
  67. package/dist/guest/external-links.d.ts +14 -0
  68. package/dist/guest/external-links.js +48 -0
  69. package/dist/guest/runtime-desktop.js +5 -2
  70. package/dist/keys/key-resolution.d.ts +10 -0
  71. package/dist/keys/key-resolution.js +33 -5
  72. package/dist/observer/data.d.ts +4 -0
  73. package/dist/observer/listen.d.ts +7 -2
  74. package/dist/observer/listen.js +10 -4
  75. package/dist/observer/render.d.ts +2 -0
  76. package/dist/observer/render.js +1 -1
  77. package/dist/observer/run-routes.js +12 -3
  78. package/dist/observer-app.html +2 -2
  79. package/dist/remote/launch.d.ts +53 -0
  80. package/dist/remote/launch.js +320 -0
  81. package/dist/remote/observer-proxy.d.ts +11 -0
  82. package/dist/remote/observer-proxy.js +66 -0
  83. package/dist/remote/plan.d.ts +35 -0
  84. package/dist/remote/plan.js +114 -0
  85. package/dist/remote/reattach.d.ts +17 -0
  86. package/dist/remote/reattach.js +81 -0
  87. package/dist/remote/runner.d.ts +68 -0
  88. package/dist/remote/runner.js +216 -0
  89. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  90. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  91. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  92. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  93. package/dist/routes/computer-use/live-phase.js +1 -1
  94. package/dist/routes/computer-use/setup.js +2 -2
  95. package/dist/routes/computer-use/types.d.ts +2 -0
  96. package/dist/routes/scripted/types.d.ts +2 -0
  97. package/dist/routes/shared-world/route.js +1 -1
  98. package/dist/routes/shared-world/types.d.ts +2 -0
  99. package/dist/routes/terminal/types.d.ts +2 -0
  100. package/dist/run/costs.d.ts +2 -1
  101. package/dist/run/display.d.ts +6 -1
  102. package/dist/run/display.js +11 -0
  103. package/dist/run/host-suspension.js +4 -1
  104. package/dist/run/preflight-receipts.d.ts +3 -12
  105. package/dist/run/preflight-receipts.js +7 -67
  106. package/dist/run/pricing.d.ts +13 -1
  107. package/dist/run/pricing.js +50 -4
  108. package/dist/run/process-owner.d.ts +28 -0
  109. package/dist/run/process-owner.js +83 -0
  110. package/dist/run/projection.js +1 -1
  111. package/dist/run/reclaim.d.ts +6 -0
  112. package/dist/run/reclaim.js +32 -2
  113. package/dist/run/results.d.ts +1 -1
  114. package/dist/run/route-shell.js +1 -1
  115. package/dist/run/run-cost.d.ts +2 -1
  116. package/dist/run/run-cost.js +6 -1
  117. package/dist/run/run-index.d.ts +3 -1
  118. package/dist/run/run-index.js +23 -11
  119. package/dist/run/run.d.ts +29 -5
  120. package/dist/run/run.js +43 -17
  121. package/dist/run/sandbox-creates.d.ts +10 -3
  122. package/dist/run/sandbox-creates.js +48 -6
  123. package/dist/run/status.d.ts +57 -2
  124. package/dist/run/status.js +87 -1
  125. package/dist/run/stored-runs.d.ts +3 -0
  126. package/dist/run/stored-runs.js +9 -4
  127. package/dist/run-study.d.ts +5 -0
  128. package/dist/study/doctor.js +14 -1
  129. package/dist/study/requirements.d.ts +1 -1
  130. package/dist/study/requirements.js +8 -6
  131. package/dist/study/route-inputs.js +4 -0
  132. package/dist/study/run-study-events.d.ts +8 -2
  133. package/dist/study/run-study-events.js +9 -2
  134. package/dist/substrates/e2b/base-module.d.ts +3 -0
  135. package/dist/substrates/e2b/base-module.js +40 -0
  136. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  137. package/dist/substrates/e2b/desktop-browser.js +27 -7
  138. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  139. package/dist/substrates/e2b/desktop-executor.js +3 -0
  140. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  141. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  142. package/dist/substrates/e2b/sdk.d.ts +24 -0
  143. package/dist/substrates/e2b/sdk.js +5 -1
  144. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  145. package/dist/substrates/e2b/shell-only.js +27 -0
  146. package/dist/substrates/local/runtime-release.js +8 -8
  147. package/dist/substrates/modal/sandbox.js +27 -28
  148. package/dist/substrates/modal/sdk.d.ts +7 -0
  149. package/dist/tui-app.js +115 -115
  150. package/docs/architecture/observer.md +7 -3
  151. package/docs/contracts/schemas.md +24 -3
  152. package/docs/contracts/study-analysis.md +52 -4
  153. package/docs/product/automatic-analysis.md +55 -9
  154. package/package.json +2 -2
  155. package/skills/humanish/SKILL.md +118 -5
@@ -3,11 +3,10 @@
3
3
  // removes the journal. A journal that stays means the probe's sandbox may still be running, and
4
4
  // `humanish reclaim --preflight` kills it by the recorded id once it is safe to.
5
5
  import { randomBytes } from "node:crypto";
6
- import { existsSync, readFileSync, readlinkSync } from "node:fs";
7
6
  import { readdir, rmdir, unlink } from "node:fs/promises";
8
- import { hostname } from "node:os";
9
7
  import path from "node:path";
10
8
  import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT } from "./sandbox-receipts.js";
9
+ import { currentProcessOwner, processOwnerGone, processOwnerOf, } from "./process-owner.js";
11
10
  import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, prepareManagedHumanishOutputDirectory, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
12
11
  const PREFLIGHT_DIR = "preflight";
13
12
  // Written when the probe finished without confirming its kill: the owner no longer holds the
@@ -15,39 +14,13 @@ const PREFLIGHT_DIR = "preflight";
15
14
  const ABANDONED_MARKER = "abandoned";
16
15
  const OWNER_FILE = "owner.json";
17
16
  const JOURNAL_NAME = /^preflight-\d+-[a-z0-9-]+$/;
18
- function ownerContext() {
19
- let pidNamespace;
20
- try {
21
- pidNamespace = readlinkSync("/proc/self/ns/pid");
22
- }
23
- catch {
24
- // No procfs: this platform has no pid namespaces to tell apart.
25
- }
26
- return { hostname: hostname(), ...(pidNamespace === undefined ? {} : { pidNamespace }) };
27
- }
28
- /** The process's start ticks; null when procfs says it is gone; undefined without procfs. */
29
- function startTicks(pid) {
30
- if (!existsSync("/proc/self/stat"))
31
- return undefined;
32
- try {
33
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
34
- // Field 22; the command name before it is parenthesized and may contain spaces.
35
- return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
36
- }
37
- catch {
38
- return null;
39
- }
40
- }
41
17
  /** Create this process's journal directory for one probe with the given lease. */
42
18
  export async function openPreflightJournal(cwd, leaseMs) {
43
19
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").toLowerCase();
44
20
  const id = `preflight-${process.pid}-${stamp}-${randomBytes(4).toString("hex")}`;
45
21
  const root = await prepareManagedHumanishOutputDirectory(cwd, PREFLIGHT_DIR, id);
46
- const ticks = startTicks(process.pid);
47
22
  const owner = {
48
- ...ownerContext(),
49
- pid: process.pid,
50
- ...(typeof ticks === "string" ? { startTicks: ticks } : {}),
23
+ ...currentProcessOwner(),
51
24
  createdAt: new Date().toISOString(),
52
25
  leaseMs,
53
26
  };
@@ -60,19 +33,12 @@ function parseOwner(read) {
60
33
  return undefined;
61
34
  try {
62
35
  const value = JSON.parse(read.bytes.toString("utf8"));
63
- if (typeof value.hostname !== "string" ||
64
- typeof value.pid !== "number" ||
36
+ const owner = processOwnerOf(value);
37
+ if (owner === undefined ||
65
38
  typeof value.createdAt !== "string" ||
66
39
  typeof value.leaseMs !== "number")
67
40
  return undefined;
68
- return {
69
- hostname: value.hostname,
70
- ...(typeof value.pidNamespace === "string" ? { pidNamespace: value.pidNamespace } : {}),
71
- pid: value.pid,
72
- ...(typeof value.startTicks === "string" ? { startTicks: value.startTicks } : {}),
73
- createdAt: value.createdAt,
74
- leaseMs: value.leaseMs,
75
- };
41
+ return { ...owner, createdAt: value.createdAt, leaseMs: value.leaseMs };
76
42
  }
77
43
  catch {
78
44
  return undefined;
@@ -133,34 +99,8 @@ export async function preflightReclaimDecision(journal, nowMs) {
133
99
  const owner = journal.owner;
134
100
  if (owner === undefined)
135
101
  return { reclaim: false, reason: `its owner record is missing or unreadable${until}` };
136
- const here = ownerContext();
137
- if (owner.hostname !== here.hostname || owner.pidNamespace !== here.pidNamespace)
138
- return {
139
- reclaim: false,
140
- reason: `it was opened on host ${owner.hostname} in another host or pid namespace, where this process cannot see whether it is still running${until}`,
141
- };
142
- const running = `process ${owner.pid} is still running${until}`;
143
- if (owner.pid === process.pid)
144
- return { reclaim: false, reason: running };
145
- const ticks = startTicks(owner.pid);
146
- if (ticks === null)
147
- return { reclaim: true };
148
- if (typeof ticks === "string") {
149
- // The pid now belongs to a different process: the owner is gone.
150
- return owner.startTicks !== undefined && ticks !== owner.startTicks
151
- ? { reclaim: true }
152
- : { reclaim: false, reason: running };
153
- }
154
- try {
155
- process.kill(owner.pid, 0);
156
- return { reclaim: false, reason: running };
157
- }
158
- catch (error) {
159
- // EPERM: the process exists but belongs to someone else.
160
- return error.code === "EPERM"
161
- ? { reclaim: false, reason: running }
162
- : { reclaim: true };
163
- }
102
+ const gone = processOwnerGone(owner);
103
+ return gone.gone ? { reclaim: true } : { reclaim: false, reason: `${gone.reason}${until}` };
164
104
  }
165
105
  /**
166
106
  * Remove a journal whose sandbox is confirmed gone. Only the files humanish writes there and the
@@ -25,6 +25,11 @@ export interface ModelRate {
25
25
  inputMultiplier: number;
26
26
  outputMultiplier: number;
27
27
  };
28
+ /** The last day (`YYYY-MM-DD`, UTC) these rates apply, when the sheet already publishes the
29
+ * rates that follow. A request is priced at the rates of the day it is sent. */
30
+ until?: string;
31
+ /** The rates the sheet publishes from the day after `until`. */
32
+ after?: Pick<ModelRate, "inputUsdPerToken" | "outputUsdPerToken" | "cachedInputUsdPerToken">;
28
33
  /** The `YYYY-MM-DD` date the entry was last checked against `source`. */
29
34
  asOf: string;
30
35
  /** Public pricing page the number came from (a comment/URL, never a secret). */
@@ -32,6 +37,8 @@ export interface ModelRate {
32
37
  /** true = a stand-in that was not copied from a live sheet; the estimate carries this flag so a
33
38
  * placeholder rate is never mistaken for a confirmed one. */
34
39
  placeholder?: boolean;
40
+ /** true = priced for study analysis only: no participant route runs this model. */
41
+ analysisOnly?: true;
35
42
  }
36
43
  export interface DesktopRate {
37
44
  usdPerMinute: number;
@@ -120,6 +127,8 @@ export declare const MODAL_SANDBOX_RESOURCE_RATE: DesktopResourceRate;
120
127
  * since an npm install cannot edit this file.
121
128
  */
122
129
  export declare function unpricedCapMessage(model: string): string;
130
+ /** A participant model's rate; undefined for a model without one or priced for analysis only. */
131
+ export declare function participantRate(model: string): ModelRate | undefined;
123
132
  export declare const DESKTOP_RATE: DesktopRate;
124
133
  export declare function isDesktopResources(value: unknown): value is DesktopResources;
125
134
  /** Price one observed allocation. Missing quantities/rates stay unknown, never a stock guess. */
@@ -141,7 +150,10 @@ export declare function estimateActorCostForExecution(usage: ActorTokenUsage | u
141
150
  * whole turn, so the per-turn records are dropped and the totals price on the base tier.
142
151
  */
143
152
  export declare function estimateAggregatedTurnCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined): ActorEstimatedCost;
144
- export declare function estimateActorCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined, rates?: Record<string, ModelRate>): ActorEstimatedCost;
153
+ /** A model's rates on the day of `at` (UTC), or undefined when the table has none. Lookup is
154
+ * case-insensitive on a trimmed id. */
155
+ export declare function modelRate(modelId: string, at?: Date, rates?: Record<string, ModelRate>): ModelRate | undefined;
156
+ export declare function estimateActorCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined, rates?: Record<string, ModelRate>, at?: Date): ActorEstimatedCost;
145
157
  /**
146
158
  * Estimate the E2B desktop-minute cost from a host-side create->teardown span (minutes). The rate
147
159
  * is injectable. Returns a declared-absent estimate (null + "no_duration") when no duration was
@@ -21,7 +21,8 @@ const LONG_CONTEXT_TIER_272K = {
21
21
  };
22
22
  const GPT56_SOURCE = "developers.openai.com/api/docs/pricing (gpt-5.6 family, standard tier)";
23
23
  const GPT6_ASTRA_SOURCE = "developers.openai.com/api/docs/pricing (gpt-6-astra, standard tier)";
24
- // One entry for a model on the gpt-5.6 pricing mechanics (the 5.6 family and gpt-6-astra): rates
24
+ const GPT6_SOL_LUNA_SOURCE = "developers.openai.com/api/docs/pricing (gpt-6.1-sol and gpt-6-luna, standard tier)";
25
+ // One entry for a model on the gpt-5.6 pricing mechanics (the 5.6 and 6 families): rates
25
26
  // in USD-per-1M for legibility, converted once. Cache writes bill at 1.25x the uncached input rate
26
27
  // (`cache_write_tokens`, prompt-caching guide), and the 272K long-context tier applies. The
27
28
  // built-in `computer` tool has no per-call fee on the live sheet.
@@ -36,10 +37,28 @@ function rateWithLongContextTier(inPer1M, cachedPer1M, writePer1M, outPer1M, asO
36
37
  source,
37
38
  };
38
39
  }
40
+ /** Gemini prices per 1M tokens: input, cached input and output (thinking included). */
41
+ function geminiPrices(inPer1M, cachedPer1M, outPer1M) {
42
+ return {
43
+ inputUsdPerToken: inPer1M * 1e-6,
44
+ cachedInputUsdPerToken: cachedPer1M * 1e-6,
45
+ outputUsdPerToken: outPer1M * 1e-6,
46
+ };
47
+ }
48
+ function geminiRate(inPer1M, cachedPer1M, outPer1M, change = {}) {
49
+ return {
50
+ ...geminiPrices(inPer1M, cachedPer1M, outPer1M),
51
+ ...change,
52
+ analysisOnly: true,
53
+ asOf: "2026-10-21",
54
+ source: "ai.google.dev/gemini-api/docs/pricing (paid tier, standard)",
55
+ };
56
+ }
39
57
  // gpt-5.6-sol promotional rates (live sheet 2026-09-03: $4 / $0.40 cached / $5 write / $20 out,
40
58
  // "available at least through November 21, 2026"). Re-verify against the sheet after Nov 21.
41
59
  const GPT56_SOL_PROMO_AS_OF = "2026-09-03";
42
60
  const GPT6_ASTRA_AS_OF = "2026-09-03";
61
+ const GPT6_SOL_LUNA_AS_OF = "2026-10-21";
43
62
  // Per-model rates, keyed on the model id that lands in trace.ids.model (lookup is
44
63
  // case-insensitive on a trimmed id). An id missing here is declared absent, never guessed.
45
64
  export const MODEL_RATES = {
@@ -82,6 +101,17 @@ export const MODEL_RATES = {
82
101
  // output ($20 / $2 / $25 / $75 long-context columns). These rates cover explicit study
83
102
  // model choices and the study-analysis default; the computer-use default is separate.
84
103
  "gpt-6-astra": rateWithLongContextTier(10, 1, 12.5, 50, GPT6_ASTRA_AS_OF, GPT6_ASTRA_SOURCE),
104
+ // gpt-6.1-sol and gpt-6-luna: the same write and long-context mechanics on the sheet and each
105
+ // model page. 6.1 Sol bills cache reads at 5% of input ($0.10), where the others bill 10%.
106
+ "gpt-6.1-sol": rateWithLongContextTier(2, 0.1, 2.5, 10, GPT6_SOL_LUNA_AS_OF, GPT6_SOL_LUNA_SOURCE),
107
+ "gpt-6-luna": rateWithLongContextTier(0.1, 0.01, 0.125, 0.5, GPT6_SOL_LUNA_AS_OF, GPT6_SOL_LUNA_SOURCE),
108
+ // Gemini analysis models, paid standard tier. Thinking tokens bill as output. No long-context
109
+ // tier is published for them. Cached input is the implicit context-cache rate. On the free tier
110
+ // these requests bill nothing, so the estimate is the paid-tier cost.
111
+ "gemini-3.8-flash": geminiRate(0.75, 0.075, 3.75, {
112
+ until: "2026-12-31",
113
+ after: geminiPrices(1.5, 0.15, 7.5),
114
+ }),
85
115
  };
86
116
  // Current public incremental running-compute rates. Subscription fees/credits, negotiated
87
117
  // enterprise prices, and unobserved allocation/startup time are outside this estimate.
@@ -114,7 +144,13 @@ export const MODAL_SANDBOX_RESOURCE_RATE = {
114
144
  * since an npm install cannot edit this file.
115
145
  */
116
146
  export function unpricedCapMessage(model) {
117
- return `caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${Object.keys(MODEL_RATES).sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
147
+ const priced = Object.keys(MODEL_RATES).filter((id) => participantRate(id) !== undefined);
148
+ return `caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${priced.sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
149
+ }
150
+ /** A participant model's rate; undefined for a model without one or priced for analysis only. */
151
+ export function participantRate(model) {
152
+ const rate = MODEL_RATES[model.trim().toLowerCase()];
153
+ return rate?.analysisOnly ? undefined : rate;
118
154
  }
119
155
  export const DESKTOP_RATE = {
120
156
  usdPerMinute: 0.00888,
@@ -204,7 +240,17 @@ export function estimateAggregatedTurnCost(tokenUsage, modelId) {
204
240
  ? estimate
205
241
  : { ...estimate, basis: "aggregated_turns_base_rate" };
206
242
  }
207
- export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES) {
243
+ /** A model's rates on the day of `at` (UTC), or undefined when the table has none. Lookup is
244
+ * case-insensitive on a trimmed id. */
245
+ export function modelRate(modelId, at = new Date(), rates = MODEL_RATES) {
246
+ const rate = rates[modelId.trim().toLowerCase()];
247
+ if (rate?.until === undefined || at.toISOString().slice(0, 10) <= rate.until)
248
+ return rate;
249
+ const { until: _until, after, cachedInputUsdPerToken: _cached, ...rest } = rate;
250
+ // Past its last day with no published successor, the entry no longer prices anything.
251
+ return after === undefined ? undefined : { ...rest, ...after };
252
+ }
253
+ export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES, at = new Date()) {
208
254
  if (!tokenUsage || (tokenUsage.input === undefined && tokenUsage.output === undefined)) {
209
255
  return {
210
256
  schema: ACTOR_ESTIMATED_COST_SCHEMA,
@@ -213,7 +259,7 @@ export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES) {
213
259
  ratesAsOf: null,
214
260
  };
215
261
  }
216
- const rate = modelId ? rates[modelId.trim().toLowerCase()] : undefined;
262
+ const rate = modelId ? modelRate(modelId, at, rates) : undefined;
217
263
  if (!rate) {
218
264
  return {
219
265
  schema: ACTOR_ESTIMATED_COST_SCHEMA,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A process, as recorded by itself. A pid means nothing outside its host and pid namespace (a
3
+ * process in a container writing to a bind-mounted checkout has a pid the host cannot see), so a
4
+ * dead pid is trusted only when both match the reader's own.
5
+ */
6
+ export interface ProcessOwner {
7
+ hostname: string;
8
+ /** `pid:[inode]` from /proc/self/ns/pid, where the platform has one. */
9
+ pidNamespace?: string;
10
+ pid: number;
11
+ /** Start time in clock ticks since boot (/proc/<pid>/stat), which tells a reused pid apart. */
12
+ startTicks?: string;
13
+ }
14
+ /** This process, as an owner record names it. */
15
+ export declare function currentProcessOwner(): ProcessOwner;
16
+ /** The owner fields of a parsed record, or undefined when `hostname` or `pid` is missing. */
17
+ export declare function processOwnerOf(value: Record<string, unknown>): ProcessOwner | undefined;
18
+ /**
19
+ * Whether `owner` is gone: it ran in this host and pid namespace, and its pid is free or now
20
+ * belongs to a process that started at another time. Otherwise the reason says why it may still
21
+ * be running.
22
+ */
23
+ export declare function processOwnerGone(owner: ProcessOwner): {
24
+ gone: true;
25
+ } | {
26
+ gone: false;
27
+ reason: string;
28
+ };
@@ -0,0 +1,83 @@
1
+ // Which process owns a file humanish leaves on disk, and whether that process is still running.
2
+ // `reclaim --preflight` reads it from a probe's journal and the analysis lock from its owner file,
3
+ // so both decide "the owner is gone" by one rule.
4
+ import { existsSync, readFileSync, readlinkSync } from "node:fs";
5
+ import { hostname } from "node:os";
6
+ function ownerContext() {
7
+ let pidNamespace;
8
+ try {
9
+ pidNamespace = readlinkSync("/proc/self/ns/pid");
10
+ }
11
+ catch {
12
+ // No procfs: this platform has no pid namespaces to tell apart.
13
+ }
14
+ return { hostname: hostname(), ...(pidNamespace === undefined ? {} : { pidNamespace }) };
15
+ }
16
+ /** The process's start ticks; null when procfs says it is gone; undefined without procfs. */
17
+ function startTicks(pid) {
18
+ if (!existsSync("/proc/self/stat"))
19
+ return undefined;
20
+ try {
21
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
22
+ // Field 22; the command name before it is parenthesized and may contain spaces.
23
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** This process, as an owner record names it. */
30
+ export function currentProcessOwner() {
31
+ const ticks = startTicks(process.pid);
32
+ return {
33
+ ...ownerContext(),
34
+ pid: process.pid,
35
+ ...(typeof ticks === "string" ? { startTicks: ticks } : {}),
36
+ };
37
+ }
38
+ /** The owner fields of a parsed record, or undefined when `hostname` or `pid` is missing. */
39
+ export function processOwnerOf(value) {
40
+ if (typeof value.hostname !== "string" || typeof value.pid !== "number")
41
+ return undefined;
42
+ return {
43
+ hostname: value.hostname,
44
+ ...(typeof value.pidNamespace === "string" ? { pidNamespace: value.pidNamespace } : {}),
45
+ pid: value.pid,
46
+ ...(typeof value.startTicks === "string" ? { startTicks: value.startTicks } : {}),
47
+ };
48
+ }
49
+ /**
50
+ * Whether `owner` is gone: it ran in this host and pid namespace, and its pid is free or now
51
+ * belongs to a process that started at another time. Otherwise the reason says why it may still
52
+ * be running.
53
+ */
54
+ export function processOwnerGone(owner) {
55
+ const here = ownerContext();
56
+ if (owner.hostname !== here.hostname || owner.pidNamespace !== here.pidNamespace)
57
+ return {
58
+ gone: false,
59
+ reason: `it was opened on host ${owner.hostname} in another host or pid namespace, where this process cannot see whether it is still running`,
60
+ };
61
+ const running = `process ${owner.pid} is still running`;
62
+ if (owner.pid === process.pid)
63
+ return { gone: false, reason: running };
64
+ const ticks = startTicks(owner.pid);
65
+ if (ticks === null)
66
+ return { gone: true };
67
+ if (typeof ticks === "string") {
68
+ // The pid now belongs to a different process: the owner is gone.
69
+ return owner.startTicks !== undefined && ticks !== owner.startTicks
70
+ ? { gone: true }
71
+ : { gone: false, reason: running };
72
+ }
73
+ try {
74
+ process.kill(owner.pid, 0);
75
+ return { gone: false, reason: running };
76
+ }
77
+ catch (error) {
78
+ // EPERM: the process exists but belongs to someone else.
79
+ return error.code === "EPERM"
80
+ ? { gone: false, reason: running }
81
+ : { gone: true };
82
+ }
83
+ }
@@ -84,7 +84,7 @@ export function expectationFor(entries, mode) {
84
84
  .filter((value) => value !== undefined)
85
85
  .sort((a, b) => a - b);
86
86
  // A cost is unknown whether it was declared absent (`null`) or never recorded at all
87
- // (`undefined`: every fail-closed exit finalized with no outcome). Counting only the first
87
+ // (`undefined`, as earlier versions left a run closed without a bundle). Counting only the first
88
88
  // reports "~$1.20 median · 3 runs" for a sample where two runs were never priced, which claims a
89
89
  // denominator the figure does not have.
90
90
  const costUnknown = finished.filter((entry) => entry.estimatedCostUsd === null || entry.estimatedCostUsd === undefined).length;
@@ -25,6 +25,12 @@ export interface ReclaimResult {
25
25
  * route created none for this run. Reclaim then contacts E2B for nothing.
26
26
  */
27
27
  reason?: NoSandboxReason;
28
+ /**
29
+ * True when this reclaim found the run's status.json still `running` with a stale heartbeat and
30
+ * recorded it `interrupted` (reason `reclaimed`): the process is gone or no longer updating it,
31
+ * and its sandboxes are being stopped. Absent when it changed nothing.
32
+ */
33
+ markedInterrupted?: true;
28
34
  warnings: string[];
29
35
  error?: {
30
36
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
@@ -12,11 +12,11 @@
12
12
  import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
13
13
  import { modalAccount } from "../substrates/modal/sandbox.js";
14
14
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
15
- import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
15
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
16
16
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
17
17
  import { readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
18
18
  import { RUN_BUNDLE_FILE } from "./bundle.js";
19
- import { RUN_STATUS_FILE } from "./status.js";
19
+ import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, } from "./status.js";
20
20
  import { isRecord } from "./type-guards.js";
21
21
  import { isRunOutcome } from "./bundle-shape.js";
22
22
  import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
@@ -66,6 +66,15 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
66
66
  },
67
67
  };
68
68
  }
69
+ // A kill by hand, not a check and not the signal handler (whose run is alive and records its
70
+ // own interrupt): a run whose heartbeat stopped is over once its sandboxes are stopped.
71
+ const marked = hooks.check !== true &&
72
+ hooks.creates === undefined &&
73
+ (await markStaleRunInterrupted(runPaths, Date.now()).catch(() => false));
74
+ const result = await reclaimLocated(runPaths, base, hooks, warnings);
75
+ return marked ? { ...result, markedInterrupted: true } : result;
76
+ }
77
+ async function reclaimLocated(runPaths, base, hooks, warnings) {
69
78
  const runId = runIdOf(runPaths);
70
79
  // The signal handler passes the creates it stopped and decides from what they reported.
71
80
  const reason = hooks.creates === undefined ? await recordedNoSandbox(runPaths) : undefined;
@@ -424,3 +433,24 @@ async function reclaimRoot(root, label, hooks, warnings) {
424
433
  createsInFlight,
425
434
  };
426
435
  }
436
+ /**
437
+ * For `humanish reclaim`, which stops the run's sandboxes: a `running` record whose heartbeat is
438
+ * stale becomes `interrupted` with reason `reclaimed` and `completedAt` at its last heartbeat. A
439
+ * fresh record belongs to a live owner and is left alone, as is one that already ended. True when
440
+ * it wrote one.
441
+ */
442
+ async function markStaleRunInterrupted(runPaths, nowMs) {
443
+ const record = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
444
+ if (!isRunStatusRecord(record) || record.state !== "running")
445
+ return false;
446
+ if (classifyRunStatus(record, nowMs) === "running")
447
+ return false;
448
+ const marked = {
449
+ ...record,
450
+ state: "interrupted",
451
+ reason: "reclaimed",
452
+ completedAt: record.updatedAt,
453
+ };
454
+ await writeContainedOutputFile(runPaths, RUN_STATUS_FILE, `${JSON.stringify(marked, null, 2)}\n`, "utf8");
455
+ return true;
456
+ }
@@ -32,7 +32,7 @@ export interface RunResult {
32
32
  observer?: ObserverResult;
33
33
  warnings: string[];
34
34
  error?: {
35
- code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
35
+ code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
36
36
  message: string;
37
37
  };
38
38
  }
@@ -30,7 +30,7 @@ export async function admitRoute(shell) {
30
30
  // The scorer joins the input only; whatever the checks admitted stays what the run uses.
31
31
  const input = shell.withScorer?.(shell.input, scorer) ?? shell.input;
32
32
  const run = async () => {
33
- const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope));
33
+ const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope), { onStart: (runId) => input.emit?.({ type: "run-started", runId }) });
34
34
  return complete(input, result, finished);
35
35
  };
36
36
  return routeOutcome(shell.route, await (shell.commsSecrets ? withTransientCommsSecrets(run) : run()));
@@ -1,6 +1,7 @@
1
1
  import type { RunCostSummary } from "./bundle.js";
2
2
  import type { AnalysisAccounting } from "./costs.js";
3
3
  import type { RunIndexEntry } from "./run-index.js";
4
+ import type { AnalysisProviderName } from "../analysis/types.js";
4
5
  /** The fields of run.json's `cost` that the run cost reads. Completeness is unknown when absent. */
5
6
  export type RunCostSubtotal = Pick<RunCostSummary, "estimatedTotalUsd"> & Partial<Pick<RunCostSummary, "fullyEstimated" | "ratesAsOf" | "placeholder">>;
6
7
  /** A run's analysis spend, every attempt counted once, as `readAnalysisAccounting` reads it. */
@@ -12,7 +13,7 @@ export interface RunAnalysisCost {
12
13
  /** True when every request has a complete price and the history was read whole. */
13
14
  complete: boolean;
14
15
  /** Who billed the requests that recorded usage. */
15
- providers: Array<"openai" | "codex">;
16
+ providers: AnalysisProviderName[];
16
17
  }
17
18
  /** Complete, a lower bound, or unknown (`null`) when only the subtotal's dollars are at hand. */
18
19
  type Completeness = boolean | null;
@@ -77,8 +77,13 @@ function estimateText(usd, complete) {
77
77
  function analysesNoun(requests) {
78
78
  return requests === 1 ? "analysis" : `${requests} analyses`;
79
79
  }
80
+ const BILLED_BY = {
81
+ openai: "OpenAI API key",
82
+ gemini: "Gemini API key",
83
+ codex: "Codex account",
84
+ };
80
85
  function billedBy(providers) {
81
- const names = providers.map((provider) => provider === "openai" ? "OpenAI API key" : "Codex account");
86
+ const names = providers.map((provider) => BILLED_BY[provider]);
82
87
  return names.length === 0 ? "" : ` (${names.join(" and ")})`;
83
88
  }
84
89
  /**
@@ -1,6 +1,6 @@
1
1
  import { AnalysisCostCache } from "./run-index-analysis.js";
2
2
  import type { RunAnalysisCost } from "./run-cost.js";
3
- import { type RunLiveness } from "./status.js";
3
+ import { type RunAnalysisPhase, type RunLiveness } from "./status.js";
4
4
  import { type RunStudyProvenance } from "./study-provenance.js";
5
5
  import type { ExecutionFailure } from "./judge.js";
6
6
  declare const RUN_INDEX_SCHEMA = "humanish.run-index.v1";
@@ -42,6 +42,8 @@ export interface RunIndexEntry {
42
42
  analysisCost?: RunAnalysisCost;
43
43
  /** Wall-clock span when both ends are known; used for per-study medians. */
44
44
  durationMs?: number;
45
+ /** The automatic analysis the run's own process started after the run ended, from status.json. */
46
+ analysis?: RunAnalysisPhase;
45
47
  }
46
48
  export interface RunIndexResult {
47
49
  schema: typeof RUN_INDEX_SCHEMA;
@@ -5,7 +5,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { runLiveness } from "./liveness.js";
7
7
  import { AnalysisCostCache, readIndexedAnalysisCost } from "./run-index-analysis.js";
8
- import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, } from "./status.js";
8
+ import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, runAnalysisPhase, } from "./status.js";
9
9
  import { studyProvenanceOf } from "./study-provenance.js";
10
10
  import { RUN_BUNDLE_FILE } from "./bundle.js";
11
11
  import { isRunOutcome } from "./bundle-shape.js";
@@ -63,6 +63,7 @@ async function readJson(file) {
63
63
  function entryFromStatus(record, nowMs) {
64
64
  const started = Date.parse(record.startedAt);
65
65
  const ended = record.completedAt === undefined ? Number.NaN : Date.parse(record.completedAt);
66
+ const analysis = runAnalysisPhase(record, nowMs);
66
67
  return {
67
68
  runId: record.runId,
68
69
  derivedFrom: "status",
@@ -88,6 +89,7 @@ function entryFromStatus(record, nowMs) {
88
89
  ? { estimatedCostComplete: record.outcome.estimatedCostComplete }
89
90
  : {}),
90
91
  ...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
92
+ ...(analysis === undefined ? {} : { analysis }),
91
93
  };
92
94
  }
93
95
  /** A status record this run can be classified from: well formed and naming this run. */
@@ -168,16 +170,8 @@ async function readEntry(runId, runDir, cache, nowMs) {
168
170
  const [statusKey, bundleKey] = await Promise.all([statKey(statusFile), statKey(bundleFile)]);
169
171
  const key = JSON.stringify([statusKey, bundleKey]);
170
172
  const cached = cache?.get(runId, key);
171
- if (cached !== undefined) {
172
- return cached.derivedFrom === "status" &&
173
- cached.updatedAt !== undefined &&
174
- cached.completedAt === undefined
175
- ? {
176
- ...cached,
177
- liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
178
- }
179
- : cached;
180
- }
173
+ if (cached !== undefined)
174
+ return withCurrentLiveness(cached, nowMs);
181
175
  const [status, raw] = await Promise.all([
182
176
  statusKey === null ? undefined : readJson(statusFile),
183
177
  bundleKey === null ? undefined : readJson(bundleFile),
@@ -196,6 +190,7 @@ async function readEntry(runId, runDir, cache, nowMs) {
196
190
  updatedAt: metadata.updatedAt,
197
191
  ...(metadata.completedAt === undefined ? {} : { completedAt: metadata.completedAt }),
198
192
  ...(metadata.durationMs === undefined ? {} : { durationMs: metadata.durationMs }),
193
+ ...(metadata.analysis === undefined ? {} : { analysis: metadata.analysis }),
199
194
  ...studyEntry(metadata.study),
200
195
  }),
201
196
  ...entryFromBundle(runId, bundle),
@@ -216,6 +211,23 @@ async function readEntry(runId, runDir, cache, nowMs) {
216
211
  cache?.set(runId, key, entry);
217
212
  return entry;
218
213
  }
214
+ /**
215
+ * A cached entry read again at `nowMs`. Its file has not changed, but a live run or a running
216
+ * analysis goes stale with time alone, so both are classified again from the cached heartbeat.
217
+ */
218
+ function withCurrentLiveness(cached, nowMs) {
219
+ if (cached.updatedAt === undefined)
220
+ return cached;
221
+ if (cached.derivedFrom === "status" && cached.completedAt === undefined)
222
+ return {
223
+ ...cached,
224
+ liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
225
+ };
226
+ if (cached.analysis === undefined || cached.analysis.state === "ended")
227
+ return cached;
228
+ const analysis = runAnalysisPhase({ updatedAt: cached.updatedAt, analysis: { startedAt: cached.analysis.startedAt } }, nowMs);
229
+ return analysis === undefined ? cached : { ...cached, analysis };
230
+ }
219
231
  /** Newest-first ordering: the most recent thing known about a run, else its id's own timestamp. */
220
232
  function sortKey(entry) {
221
233
  for (const candidate of [entry.completedAt, entry.updatedAt, entry.startedAt]) {