@aloud/runner 0.2.3 → 0.2.5

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.
package/dist/cli.js CHANGED
@@ -59,6 +59,16 @@ var init_ids = __esm({
59
59
  ledgerEntry: "led",
60
60
  // BILLING_PLAN 2.1: a grant of credit with its own expiry, which is what a balance is made of.
61
61
  creditLot: "clt",
62
+ // BILLING_PLAN 3: the estimate range and approved ceiling a run was authorised under. Stored
63
+ // rather than displayed, because a variable meter with no recorded agreement is a dispute.
64
+ quote: "qte",
65
+ // BILLING_PLAN 4: the billing period a workspace was on. The tier belongs to the period rather
66
+ // than to the workspace row, because a workspace outlives its plan.
67
+ plan: "pln",
68
+ // BILLING_PLAN 4: one meter event owed to Stripe. This is the row identity the drain worker
69
+ // uses, NOT what is sent as the dedup identifier: that is the ledger entry id the row carries,
70
+ // so that Stripe deduplication and the local unique index agree on what a duplicate is.
71
+ outboxEvent: "obx",
62
72
  rateCard: "rtc",
63
73
  auditEvent: "aud",
64
74
  job: "job",
@@ -1107,41 +1117,110 @@ var init_moment = __esm({
1107
1117
  }
1108
1118
  });
1109
1119
 
1110
- // ../core/src/schemas/study.ts
1120
+ // ../core/src/schemas/session.ts
1111
1121
  import { z as z9 } from "zod";
1122
+ var SessionLimits, SessionError, SessionEndReason, Session;
1123
+ var init_session = __esm({
1124
+ "../core/src/schemas/session.ts"() {
1125
+ "use strict";
1126
+ init_persona();
1127
+ init_patience();
1128
+ init_machines();
1129
+ init_action();
1130
+ SessionLimits = z9.object({
1131
+ maxActions: z9.number().int().positive(),
1132
+ maxElapsedMs: z9.number().int().positive(),
1133
+ maxTokens: z9.number().int().positive()
1134
+ });
1135
+ SessionError = z9.object({
1136
+ kind: z9.enum(["browser", "model", "policy", "timeout", "storage", "internal"]),
1137
+ message: z9.string(),
1138
+ retryable: z9.boolean(),
1139
+ occurredAt: z9.string()
1140
+ });
1141
+ SessionEndReason = z9.enum([
1142
+ "finished",
1143
+ "abandoned",
1144
+ "cap_actions",
1145
+ "cap_elapsed",
1146
+ "cap_tokens",
1147
+ "error",
1148
+ "canceled"
1149
+ ]);
1150
+ Session = z9.object({
1151
+ id: z9.string(),
1152
+ runId: z9.string(),
1153
+ workspaceId: z9.string(),
1154
+ persona: PersonaBrief,
1155
+ personaId: z9.string().nullable(),
1156
+ status: SessionStatus,
1157
+ /** SPEC FR-054: recorded as evidence, never the reported result. */
1158
+ selfReportedOutcome: SessionOutcome.nullable(),
1159
+ selfReportedReason: z9.string().nullable(),
1160
+ patienceBudget: PatienceBudget,
1161
+ patienceState: PatienceState,
1162
+ limits: SessionLimits,
1163
+ startedAt: z9.string().nullable(),
1164
+ endedAt: z9.string().nullable(),
1165
+ error: SessionError.nullable(),
1166
+ /** SPEC 14.4: set when integrity checks exclude or flag this session. */
1167
+ contamination: z9.object({
1168
+ contaminated: z9.boolean(),
1169
+ flaggedOnly: z9.boolean(),
1170
+ signals: z9.array(z9.string())
1171
+ }).nullable(),
1172
+ momentCount: z9.number().int().nonnegative().default(0),
1173
+ /**
1174
+ * Why the loop stopped. Null only for a session recorded before this field existed.
1175
+ *
1176
+ * Nullable rather than defaulted, per BILLING_PLAN Part 4 ("new nullable columns", additive only):
1177
+ * a historical session has no honest value, and inventing one from its status would manufacture
1178
+ * exactly the signal this field exists to stop anybody guessing at. Null therefore means "not
1179
+ * recorded", never "ended cleanly".
1180
+ */
1181
+ endReason: SessionEndReason.nullable(),
1182
+ /** Which browser worker ran it, for the isolation audit trail (SPEC FR-040). */
1183
+ workerId: z9.string().nullable()
1184
+ });
1185
+ }
1186
+ });
1187
+
1188
+ // ../core/src/schemas/study.ts
1189
+ import { z as z10 } from "zod";
1112
1190
  var EnvironmentType, ActionPolicy, Viewport, VIEWPORT_PRESETS, Environment, Product, SuccessCriterion, StudyStatus, Study, StudySnapshot;
1113
1191
  var init_study = __esm({
1114
1192
  "../core/src/schemas/study.ts"() {
1115
1193
  "use strict";
1116
1194
  init_persona();
1117
- EnvironmentType = z9.enum(["production", "staging", "preview", "local", "benchmark"]);
1118
- ActionPolicy = z9.object({
1119
- blockConsequentialActions: z9.boolean().default(true),
1120
- allowFormSubmission: z9.boolean().default(false),
1121
- allowedDomains: z9.array(z9.string()).default([]),
1195
+ init_session();
1196
+ EnvironmentType = z10.enum(["production", "staging", "preview", "local", "benchmark"]);
1197
+ ActionPolicy = z10.object({
1198
+ blockConsequentialActions: z10.boolean().default(true),
1199
+ allowFormSubmission: z10.boolean().default(false),
1200
+ allowedDomains: z10.array(z10.string()).default([]),
1122
1201
  /** SPEC FR-014: redirects outside allowed domains are blocked or need approval. */
1123
- blockOffDomainNavigation: z9.boolean().default(true),
1202
+ blockOffDomainNavigation: z10.boolean().default(true),
1124
1203
  /** SPEC FR-050/FR-055: raw secret entry by the participant is always blocked. */
1125
- allowSecretEntry: z9.literal(false).default(false)
1204
+ allowSecretEntry: z10.literal(false).default(false)
1126
1205
  });
1127
- Viewport = z9.object({
1128
- width: z9.number().int().min(320).max(3840),
1129
- height: z9.number().int().min(480).max(2160),
1130
- deviceScaleFactor: z9.number().min(1).max(3).default(1),
1131
- isMobile: z9.boolean().default(false)
1206
+ Viewport = z10.object({
1207
+ width: z10.number().int().min(320).max(3840),
1208
+ height: z10.number().int().min(480).max(2160),
1209
+ deviceScaleFactor: z10.number().min(1).max(3).default(1),
1210
+ isMobile: z10.boolean().default(false)
1132
1211
  });
1133
1212
  VIEWPORT_PRESETS = {
1134
1213
  desktop: { width: 1440, height: 900, deviceScaleFactor: 1, isMobile: false },
1135
1214
  mobile_web: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true }
1136
1215
  };
1137
- Environment = z9.object({
1138
- id: z9.string(),
1139
- workspaceId: z9.string(),
1140
- productId: z9.string(),
1141
- name: z9.string().min(1).max(120),
1142
- baseUrl: z9.string().url(),
1216
+ Environment = z10.object({
1217
+ id: z10.string(),
1218
+ workspaceId: z10.string(),
1219
+ productId: z10.string(),
1220
+ name: z10.string().min(1).max(120),
1221
+ baseUrl: z10.string().url(),
1143
1222
  type: EnvironmentType,
1144
- allowedDomains: z9.array(z9.string()).default([]),
1223
+ allowedDomains: z10.array(z10.string()).default([]),
1145
1224
  defaultViewport: DeviceContext.default("desktop"),
1146
1225
  actionPolicy: ActionPolicy,
1147
1226
  /**
@@ -1149,147 +1228,163 @@ var init_study = __esm({
1149
1228
  * A promise that secrets never appear in a screenshot is not enforceable against a live app, so
1150
1229
  * the policy is environment-scoped rather than absolute.
1151
1230
  */
1152
- redactionRequired: z9.boolean(),
1153
- createdAt: z9.string(),
1231
+ redactionRequired: z10.boolean(),
1232
+ createdAt: z10.string(),
1154
1233
  /** Marked when the system inferred this rather than the user configuring it (SPEC 9.0). */
1155
- inferred: z9.boolean().default(false)
1234
+ inferred: z10.boolean().default(false)
1156
1235
  });
1157
- Product = z9.object({
1158
- id: z9.string(),
1159
- workspaceId: z9.string(),
1160
- name: z9.string().min(1).max(120),
1161
- description: z9.string().max(2e3).default(""),
1162
- createdAt: z9.string(),
1163
- inferred: z9.boolean().default(false)
1236
+ Product = z10.object({
1237
+ id: z10.string(),
1238
+ workspaceId: z10.string(),
1239
+ name: z10.string().min(1).max(120),
1240
+ description: z10.string().max(2e3).default(""),
1241
+ createdAt: z10.string(),
1242
+ inferred: z10.boolean().default(false)
1164
1243
  });
1165
- SuccessCriterion = z9.object({
1166
- id: z9.string(),
1244
+ SuccessCriterion = z10.object({
1245
+ id: z10.string(),
1167
1246
  /** What a judge could confirm from a screenshot, in plain language. */
1168
- statement: z9.string().min(1).max(400),
1247
+ statement: z10.string().min(1).max(400),
1169
1248
  /** Which visible signal confirms it. */
1170
- observableSignal: z9.string().min(1).max(400),
1171
- required: z9.boolean().default(true),
1249
+ observableSignal: z10.string().min(1).max(400),
1250
+ required: z10.boolean().default(true),
1172
1251
  /** Marked when the system proposed it (SPEC FR-026). */
1173
- proposed: z9.boolean().default(false)
1252
+ proposed: z10.boolean().default(false)
1174
1253
  });
1175
- StudyStatus = z9.enum(["draft", "ready", "archived"]);
1176
- Study = z9.object({
1177
- id: z9.string(),
1178
- workspaceId: z9.string(),
1179
- productId: z9.string(),
1180
- environmentId: z9.string(),
1181
- name: z9.string().min(1).max(160),
1254
+ StudyStatus = z10.enum(["draft", "ready", "archived"]);
1255
+ Study = z10.object({
1256
+ id: z10.string(),
1257
+ workspaceId: z10.string(),
1258
+ productId: z10.string(),
1259
+ environmentId: z10.string(),
1260
+ name: z10.string().min(1).max(160),
1182
1261
  /** SPEC FR-020: the flow under test. */
1183
- targetFlow: z9.string().min(1).max(400),
1184
- scenario: z9.string().min(1).max(2e3),
1262
+ targetFlow: z10.string().min(1).max(400),
1263
+ scenario: z10.string().min(1).max(2e3),
1185
1264
  /** In user language. Never a step-by-step script (SPEC 26 research integrity). */
1186
- goal: z9.string().min(1).max(600),
1187
- successCriteria: z9.array(SuccessCriterion).min(1),
1265
+ goal: z10.string().min(1).max(600),
1266
+ successCriteria: z10.array(SuccessCriterion).min(1),
1188
1267
  device: DeviceContext,
1189
- participantCount: z9.number().int().min(1).max(12),
1268
+ participantCount: z10.number().int().min(1).max(12),
1190
1269
  /** Drives patience budget compilation (SPEC 14.5). */
1191
- expectedSteps: z9.number().int().min(1).max(60).default(6),
1192
- startUrl: z9.string().url(),
1270
+ expectedSteps: z10.number().int().min(1).max(60).default(6),
1271
+ startUrl: z10.string().url(),
1193
1272
  status: StudyStatus,
1194
- createdAt: z9.string(),
1195
- updatedAt: z9.string(),
1196
- inferred: z9.boolean().default(false)
1273
+ createdAt: z10.string(),
1274
+ updatedAt: z10.string(),
1275
+ inferred: z10.boolean().default(false)
1197
1276
  });
1198
- StudySnapshot = z9.object({
1277
+ StudySnapshot = z10.object({
1199
1278
  study: Study,
1200
1279
  environment: Environment,
1201
- cast: z9.array(PersonaBrief).min(1),
1280
+ cast: z10.array(PersonaBrief).min(1),
1202
1281
  viewport: Viewport,
1203
- promptVersion: z9.string(),
1204
- actionSchemaVersion: z9.string(),
1205
- capturedAt: z9.string()
1282
+ promptVersion: z10.string(),
1283
+ actionSchemaVersion: z10.string(),
1284
+ /**
1285
+ * BILLING_PLAN 3: the per-session ceiling this run was authorised under, derived from the
1286
+ * approved quote's cap by `maxActionsForCap`.
1287
+ *
1288
+ * It belongs in the snapshot for the same reason the cast and the environment do. FR-025 freezes
1289
+ * a run's configuration so that editing the study afterwards cannot change what an existing
1290
+ * report claims was tested, and the ceiling an operator approved is part of that configuration:
1291
+ * re-quoting a study next month must not retroactively widen or narrow a run that already
1292
+ * happened. It is also what carries the cap to a local runner, which works from the lease's
1293
+ * snapshot and never sees `study_quotes`.
1294
+ *
1295
+ * Optional, and null-safe on read. Runs that predate BILLING_PLAN 3 carry no quote, and a stored
1296
+ * snapshot is not rewritten to add one; those fall back to the platform default in the
1297
+ * coordinator rather than being refused years later.
1298
+ */
1299
+ sessionLimits: SessionLimits.optional(),
1300
+ capturedAt: z10.string()
1206
1301
  });
1207
1302
  }
1208
1303
  });
1209
1304
 
1210
1305
  // ../core/src/schemas/product-context.ts
1211
- import { z as z10 } from "zod";
1306
+ import { z as z11 } from "zod";
1212
1307
  var ProductEvidenceKind, ProductEvidenceRef, AudienceSegment, ProductFlow, ProductProfile, PersonaProposal, DiscoveryLink, DiscoveryPage, ProductDiscoveryEvidence, StudySetupStatus, StudySetupJob;
1213
1308
  var init_product_context = __esm({
1214
1309
  "../core/src/schemas/product-context.ts"() {
1215
1310
  "use strict";
1216
1311
  init_persona();
1217
- ProductEvidenceKind = z10.enum([
1312
+ ProductEvidenceKind = z11.enum([
1218
1313
  "rendered_page",
1219
1314
  "user_statement",
1220
1315
  "agent_summary",
1221
1316
  "local_manifest"
1222
1317
  ]);
1223
- ProductEvidenceRef = z10.object({
1224
- id: z10.string(),
1318
+ ProductEvidenceRef = z11.object({
1319
+ id: z11.string(),
1225
1320
  kind: ProductEvidenceKind,
1226
1321
  /** A human-readable source name, such as "Pricing" or "Project summary". */
1227
- label: z10.string().min(1).max(160),
1322
+ label: z11.string().min(1).max(160),
1228
1323
  /** URL for rendered evidence; a non-sensitive logical locator for local context. */
1229
- locator: z10.string().min(1).max(1e3),
1324
+ locator: z11.string().min(1).max(1e3),
1230
1325
  /** Only customer-facing copy may be retained here. Raw repository source never belongs here. */
1231
- excerpt: z10.string().max(800).nullable().default(null),
1232
- contentHash: z10.string().max(128).nullable().default(null),
1233
- capturedAt: z10.string()
1326
+ excerpt: z11.string().max(800).nullable().default(null),
1327
+ contentHash: z11.string().max(128).nullable().default(null),
1328
+ capturedAt: z11.string()
1234
1329
  });
1235
- AudienceSegment = z10.object({
1236
- id: z10.string(),
1237
- label: z10.string().min(1).max(120),
1238
- description: z10.string().min(1).max(600),
1239
- roles: z10.array(z10.string().min(1).max(120)).max(12).default([]),
1240
- jobsToBeDone: z10.array(z10.string().min(1).max(400)).max(12).default([]),
1241
- evidenceIds: z10.array(z10.string()).min(1),
1242
- confidence: z10.number().min(0).max(1)
1330
+ AudienceSegment = z11.object({
1331
+ id: z11.string(),
1332
+ label: z11.string().min(1).max(120),
1333
+ description: z11.string().min(1).max(600),
1334
+ roles: z11.array(z11.string().min(1).max(120)).max(12).default([]),
1335
+ jobsToBeDone: z11.array(z11.string().min(1).max(400)).max(12).default([]),
1336
+ evidenceIds: z11.array(z11.string()).min(1),
1337
+ confidence: z11.number().min(0).max(1)
1243
1338
  });
1244
- ProductFlow = z10.object({
1245
- label: z10.string().min(1).max(160),
1246
- outcome: z10.string().min(1).max(400),
1247
- evidenceIds: z10.array(z10.string()).min(1)
1339
+ ProductFlow = z11.object({
1340
+ label: z11.string().min(1).max(160),
1341
+ outcome: z11.string().min(1).max(400),
1342
+ evidenceIds: z11.array(z11.string()).min(1)
1248
1343
  });
1249
- ProductProfile = z10.object({
1250
- id: z10.string(),
1251
- workspaceId: z10.string(),
1252
- productId: z10.string(),
1253
- version: z10.number().int().positive(),
1254
- name: z10.string().min(1).max(120),
1255
- category: z10.string().min(1).max(160),
1256
- summary: z10.string().min(1).max(1200),
1257
- valueProposition: z10.string().max(800).default(""),
1258
- customerTypes: z10.array(z10.string().min(1).max(160)).max(12).default([]),
1259
- audiences: z10.array(AudienceSegment).max(16).default([]),
1260
- keyFlows: z10.array(ProductFlow).max(20).default([]),
1261
- domainVocabulary: z10.array(z10.string().min(1).max(100)).max(80).default([]),
1262
- evidence: z10.array(ProductEvidenceRef).max(80).default([]),
1263
- confidence: z10.number().min(0).max(1),
1264
- sourceFingerprint: z10.string().min(1).max(128),
1265
- supersedesId: z10.string().nullable(),
1266
- createdBy: z10.enum(["system", "user", "agent"]),
1267
- createdAt: z10.string()
1344
+ ProductProfile = z11.object({
1345
+ id: z11.string(),
1346
+ workspaceId: z11.string(),
1347
+ productId: z11.string(),
1348
+ version: z11.number().int().positive(),
1349
+ name: z11.string().min(1).max(120),
1350
+ category: z11.string().min(1).max(160),
1351
+ summary: z11.string().min(1).max(1200),
1352
+ valueProposition: z11.string().max(800).default(""),
1353
+ customerTypes: z11.array(z11.string().min(1).max(160)).max(12).default([]),
1354
+ audiences: z11.array(AudienceSegment).max(16).default([]),
1355
+ keyFlows: z11.array(ProductFlow).max(20).default([]),
1356
+ domainVocabulary: z11.array(z11.string().min(1).max(100)).max(80).default([]),
1357
+ evidence: z11.array(ProductEvidenceRef).max(80).default([]),
1358
+ confidence: z11.number().min(0).max(1),
1359
+ sourceFingerprint: z11.string().min(1).max(128),
1360
+ supersedesId: z11.string().nullable(),
1361
+ createdBy: z11.enum(["system", "user", "agent"]),
1362
+ createdAt: z11.string()
1268
1363
  });
1269
- PersonaProposal = z10.object({
1364
+ PersonaProposal = z11.object({
1270
1365
  brief: PersonaBrief,
1271
- audienceSegmentId: z10.string().nullable(),
1272
- rationale: z10.string().min(1).max(600),
1273
- evidenceIds: z10.array(z10.string()).default([]),
1274
- confidence: z10.number().min(0).max(1)
1366
+ audienceSegmentId: z11.string().nullable(),
1367
+ rationale: z11.string().min(1).max(600),
1368
+ evidenceIds: z11.array(z11.string()).default([]),
1369
+ confidence: z11.number().min(0).max(1)
1275
1370
  });
1276
- DiscoveryLink = z10.object({
1277
- href: z10.string().url(),
1278
- text: z10.string().max(240)
1371
+ DiscoveryLink = z11.object({
1372
+ href: z11.string().url(),
1373
+ text: z11.string().max(240)
1279
1374
  });
1280
- DiscoveryPage = z10.object({
1281
- url: z10.string().url(),
1282
- title: z10.string().max(300),
1283
- headings: z10.array(z10.string().max(300)).max(80),
1284
- visibleText: z10.string().max(3e4),
1285
- links: z10.array(DiscoveryLink).max(200)
1375
+ DiscoveryPage = z11.object({
1376
+ url: z11.string().url(),
1377
+ title: z11.string().max(300),
1378
+ headings: z11.array(z11.string().max(300)).max(80),
1379
+ visibleText: z11.string().max(3e4),
1380
+ links: z11.array(DiscoveryLink).max(200)
1286
1381
  });
1287
- ProductDiscoveryEvidence = z10.object({
1288
- pages: z10.array(DiscoveryPage).min(1).max(8),
1289
- sourceFingerprint: z10.string().min(1).max(128),
1290
- capturedAt: z10.string()
1382
+ ProductDiscoveryEvidence = z11.object({
1383
+ pages: z11.array(DiscoveryPage).min(1).max(8),
1384
+ sourceFingerprint: z11.string().min(1).max(128),
1385
+ capturedAt: z11.string()
1291
1386
  });
1292
- StudySetupStatus = z10.enum([
1387
+ StudySetupStatus = z11.enum([
1293
1388
  "awaiting_runner",
1294
1389
  "discovering",
1295
1390
  "analysing",
@@ -1299,77 +1394,27 @@ var init_product_context = __esm({
1299
1394
  "failed",
1300
1395
  "expired"
1301
1396
  ]);
1302
- StudySetupJob = z10.object({
1303
- id: z10.string(),
1304
- workspaceId: z10.string(),
1305
- url: z10.string().url(),
1306
- goal: z10.string().min(1).max(600),
1397
+ StudySetupJob = z11.object({
1398
+ id: z11.string(),
1399
+ workspaceId: z11.string(),
1400
+ url: z11.string().url(),
1401
+ goal: z11.string().min(1).max(600),
1307
1402
  device: DeviceContext,
1308
- participantCount: z10.number().int().min(1).max(8),
1309
- allowedHosts: z10.array(z10.string()).min(1),
1403
+ participantCount: z11.number().int().min(1).max(8),
1404
+ allowedHosts: z11.array(z11.string()).min(1),
1310
1405
  status: StudySetupStatus,
1311
- runnerId: z10.string().nullable(),
1406
+ runnerId: z11.string().nullable(),
1312
1407
  discoveryEvidence: ProductDiscoveryEvidence.nullable(),
1313
- productId: z10.string().nullable(),
1314
- profileId: z10.string().nullable(),
1315
- studyId: z10.string().nullable(),
1316
- personaProposals: z10.array(PersonaProposal).default([]),
1317
- degradedReason: z10.string().max(1e3).nullable(),
1318
- failureReason: z10.string().max(1e3).nullable(),
1319
- createdAt: z10.string(),
1320
- claimedAt: z10.string().nullable(),
1321
- expiresAt: z10.string(),
1322
- completedAt: z10.string().nullable()
1323
- });
1324
- }
1325
- });
1326
-
1327
- // ../core/src/schemas/session.ts
1328
- import { z as z11 } from "zod";
1329
- var SessionLimits, SessionError, Session;
1330
- var init_session = __esm({
1331
- "../core/src/schemas/session.ts"() {
1332
- "use strict";
1333
- init_persona();
1334
- init_patience();
1335
- init_machines();
1336
- init_action();
1337
- SessionLimits = z11.object({
1338
- maxActions: z11.number().int().positive(),
1339
- maxElapsedMs: z11.number().int().positive(),
1340
- maxTokens: z11.number().int().positive()
1341
- });
1342
- SessionError = z11.object({
1343
- kind: z11.enum(["browser", "model", "policy", "timeout", "storage", "internal"]),
1344
- message: z11.string(),
1345
- retryable: z11.boolean(),
1346
- occurredAt: z11.string()
1347
- });
1348
- Session = z11.object({
1349
- id: z11.string(),
1350
- runId: z11.string(),
1351
- workspaceId: z11.string(),
1352
- persona: PersonaBrief,
1353
- personaId: z11.string().nullable(),
1354
- status: SessionStatus,
1355
- /** SPEC FR-054: recorded as evidence, never the reported result. */
1356
- selfReportedOutcome: SessionOutcome.nullable(),
1357
- selfReportedReason: z11.string().nullable(),
1358
- patienceBudget: PatienceBudget,
1359
- patienceState: PatienceState,
1360
- limits: SessionLimits,
1361
- startedAt: z11.string().nullable(),
1362
- endedAt: z11.string().nullable(),
1363
- error: SessionError.nullable(),
1364
- /** SPEC 14.4: set when integrity checks exclude or flag this session. */
1365
- contamination: z11.object({
1366
- contaminated: z11.boolean(),
1367
- flaggedOnly: z11.boolean(),
1368
- signals: z11.array(z11.string())
1369
- }).nullable(),
1370
- momentCount: z11.number().int().nonnegative().default(0),
1371
- /** Which browser worker ran it, for the isolation audit trail (SPEC FR-040). */
1372
- workerId: z11.string().nullable()
1408
+ productId: z11.string().nullable(),
1409
+ profileId: z11.string().nullable(),
1410
+ studyId: z11.string().nullable(),
1411
+ personaProposals: z11.array(PersonaProposal).default([]),
1412
+ degradedReason: z11.string().max(1e3).nullable(),
1413
+ failureReason: z11.string().max(1e3).nullable(),
1414
+ createdAt: z11.string(),
1415
+ claimedAt: z11.string().nullable(),
1416
+ expiresAt: z11.string(),
1417
+ completedAt: z11.string().nullable()
1373
1418
  });
1374
1419
  }
1375
1420
  });
@@ -1850,7 +1895,7 @@ var init_recommendation = __esm({
1850
1895
 
1851
1896
  // ../core/src/schemas/billing.ts
1852
1897
  import { z as z18 } from "zod";
1853
- var RateCard, ChargeLineKind, ChargeLine;
1898
+ var RateCard, ChargeLineKind, ChargeLine, StudyEstimate, EstimateBasis, StudyQuote;
1854
1899
  var init_billing = __esm({
1855
1900
  "../core/src/schemas/billing.ts"() {
1856
1901
  "use strict";
@@ -1892,6 +1937,44 @@ var init_billing = __esm({
1892
1937
  */
1893
1938
  idempotencyKey: z18.string()
1894
1939
  });
1940
+ StudyEstimate = z18.object({
1941
+ /** The optimistic end: participants who move through the flow faster than predicted. */
1942
+ low: z18.number().int().nonnegative(),
1943
+ /** The midpoint, priced at the predicted moment count exactly. */
1944
+ expected: z18.number().int().nonnegative(),
1945
+ /** The pessimistic end: participants who flounder, which is also where the findings are. */
1946
+ high: z18.number().int().nonnegative(),
1947
+ /** At or above `high` with headroom. See CAP_HEADROOM in ../billing.ts for the policy. */
1948
+ cap: z18.number().int().nonnegative()
1949
+ });
1950
+ EstimateBasis = z18.enum(["archetype", "history"]);
1951
+ StudyQuote = z18.object({
1952
+ id: z18.string(),
1953
+ workspaceId: z18.string(),
1954
+ /** The run this quote priced. Not a foreign key in the schema: an agreement outlives its run. */
1955
+ runId: z18.string(),
1956
+ /** I6: the immutable card the range was computed under, so the range stays reproducible. */
1957
+ rateCardVersion: z18.string(),
1958
+ /** Non-negative rather than positive: a zero-participant study still reaches synthesis, and a
1959
+ * rate card is free to price any fixed line at zero. Negative is the only impossible value. */
1960
+ estimateLowCredits: z18.number().int().nonnegative(),
1961
+ estimateHighCredits: z18.number().int().nonnegative(),
1962
+ /** The ceiling the operator approves, and the number SessionLimits.maxActions is derived from. */
1963
+ approvedCapCredits: z18.number().int().nonnegative(),
1964
+ /** Null until an operator agrees. Both approval fields are set together or not at all. */
1965
+ approvedBy: z18.string().nullable(),
1966
+ approvedAt: z18.string().nullable(),
1967
+ createdAt: z18.string()
1968
+ }).refine((q) => q.estimateLowCredits <= q.estimateHighCredits, {
1969
+ message: "estimateLowCredits must not exceed estimateHighCredits",
1970
+ path: ["estimateLowCredits"]
1971
+ }).refine((q) => q.approvedCapCredits >= q.estimateHighCredits, {
1972
+ message: "approvedCapCredits must be at least estimateHighCredits",
1973
+ path: ["approvedCapCredits"]
1974
+ }).refine((q) => q.approvedBy === null === (q.approvedAt === null), {
1975
+ message: "approvedBy and approvedAt are set together or not at all",
1976
+ path: ["approvedAt"]
1977
+ });
1895
1978
  }
1896
1979
  });
1897
1980
 
@@ -2495,9 +2578,10 @@ var init_src = __esm({
2495
2578
  init_src();
2496
2579
  import { createInterface } from "node:readline/promises";
2497
2580
  import { spawn as spawn2 } from "node:child_process";
2498
- import { accessSync, constants as constants2, existsSync as existsSync2, readFileSync, unlinkSync } from "node:fs";
2581
+ import { mkdir as mkdir4 } from "node:fs/promises";
2582
+ import { accessSync, constants as constants2, existsSync as existsSync2, openSync, readFileSync, unlinkSync } from "node:fs";
2499
2583
  import { hostname } from "node:os";
2500
- import { delimiter, join as join5 } from "node:path";
2584
+ import { delimiter, dirname as dirname4, join as join5 } from "node:path";
2501
2585
 
2502
2586
  // src/config/credentials.ts
2503
2587
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
@@ -2666,7 +2750,7 @@ function alive(pid) {
2666
2750
  }
2667
2751
 
2668
2752
  // src/version.ts
2669
- var RUNNER_VERSION = "0.2.3";
2753
+ var RUNNER_VERSION = "0.2.5";
2670
2754
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
2671
2755
 
2672
2756
  // src/protocol/client.ts
@@ -3988,6 +4072,7 @@ var ParticipantSession = class {
3988
4072
  const startedAt = this.deps.clock.nowIso();
3989
4073
  this.startedAtMs = this.deps.clock.monotonicMs();
3990
4074
  let status2 = "active";
4075
+ let endReason = "error";
3991
4076
  let error = null;
3992
4077
  try {
3993
4078
  this.emit({ type: "session.started", sessionId: this.input.sessionId, persona: this.persona.name });
@@ -3995,12 +4080,14 @@ var ParticipantSession = class {
3995
4080
  this.lastCapture = first;
3996
4081
  this.visited.push(first.digest);
3997
4082
  await this.recordFirstImpression(first);
3998
- status2 = await this.loop();
4083
+ ({ status: status2, endReason } = await this.loop());
3999
4084
  } catch (thrown) {
4000
4085
  if (this.deps.signal?.aborted) {
4001
4086
  status2 = "canceled";
4087
+ endReason = "canceled";
4002
4088
  } else {
4003
4089
  status2 = "error";
4090
+ endReason = "error";
4004
4091
  const modelError = thrown instanceof ModelError ? thrown : null;
4005
4092
  error = {
4006
4093
  kind: modelError ? `model:${modelError.kind}` : "browser",
@@ -4015,6 +4102,7 @@ var ParticipantSession = class {
4015
4102
  return {
4016
4103
  sessionId: this.input.sessionId,
4017
4104
  status: status2,
4105
+ endReason,
4018
4106
  moments: this.moments,
4019
4107
  evidenceAssets: this.evidenceAssets,
4020
4108
  selfReportedOutcome: this.selfReportedOutcome,
@@ -4037,12 +4125,23 @@ var ParticipantSession = class {
4037
4125
  throw error;
4038
4126
  }
4039
4127
  }
4128
+ /**
4129
+ * Returns both how the session ended and why.
4130
+ *
4131
+ * Both, rather than the status alone, because the status cannot carry the why: three of the exits
4132
+ * below are all `partial`, and MONETIZATION "Refunds" bills two of them and refunds the third
4133
+ * whole. This function is the only place in the system that knows which one was taken, so the
4134
+ * reason leaves here as data rather than being reconstructed later from a moment count that reads
4135
+ * identically either way.
4136
+ */
4040
4137
  async loop() {
4041
4138
  const { limits } = this.input;
4042
4139
  for (let step = 0; step < limits.maxActions; step += 1) {
4043
4140
  this.checkCancelled();
4044
- if (this.elapsedMs() > limits.maxElapsedMs) return "partial";
4045
- if (this.deps.gateway.totalUsage().outputTokens > limits.maxTokens) return "partial";
4141
+ if (this.elapsedMs() > limits.maxElapsedMs) return { status: "partial", endReason: "cap_elapsed" };
4142
+ if (this.deps.gateway.totalUsage().outputTokens > limits.maxTokens) {
4143
+ return { status: "partial", endReason: "cap_tokens" };
4144
+ }
4046
4145
  const capture = this.lastCapture;
4047
4146
  const momentId = newId("moment");
4048
4147
  const decision = await this.decide(capture, momentId);
@@ -4058,12 +4157,12 @@ var ParticipantSession = class {
4058
4157
  confidence: decision.confidence,
4059
4158
  capture
4060
4159
  });
4061
- return decision.action.outcome === "abandoned" ? "abandoned" : "partial";
4160
+ return decision.action.outcome === "abandoned" ? { status: "abandoned", endReason: "abandoned" } : { status: "partial", endReason: "finished" };
4062
4161
  }
4063
4162
  const outcome = await this.act(decision, capture, momentId);
4064
- if (outcome === "stop") return "abandoned";
4163
+ if (outcome === "stop") return { status: "abandoned", endReason: "abandoned" };
4065
4164
  }
4066
- return "partial";
4165
+ return { status: "partial", endReason: "cap_actions" };
4067
4166
  }
4068
4167
  async decide(capture, momentId) {
4069
4168
  const result = await this.deps.gateway.generate({
@@ -5022,6 +5121,11 @@ var RunEventLog = class {
5022
5121
 
5023
5122
  // ../engine/src/orchestrator/coordinator.ts
5024
5123
  init_src();
5124
+ var DEFAULT_SESSION_LIMITS = {
5125
+ maxActions: 40,
5126
+ maxElapsedMs: 15 * 6e4,
5127
+ maxTokens: 4e5
5128
+ };
5025
5129
  var RunCoordinator = class {
5026
5130
  constructor(input, deps) {
5027
5131
  this.input = input;
@@ -5160,6 +5264,7 @@ var RunCoordinator = class {
5160
5264
  */
5161
5265
  async runSessions(budgets) {
5162
5266
  const { snapshot } = this.input;
5267
+ const limits = snapshot.sessionLimits ?? DEFAULT_SESSION_LIMITS;
5163
5268
  const limit = Math.max(1, this.deps.maxConcurrentSessions ?? snapshot.cast.length);
5164
5269
  const results = new Array(snapshot.cast.length);
5165
5270
  let cursor = 0;
@@ -5181,7 +5286,7 @@ var RunCoordinator = class {
5181
5286
  persona,
5182
5287
  snapshot,
5183
5288
  patienceBudget: budgets[index],
5184
- limits: { maxActions: 40, maxElapsedMs: 15 * 6e4, maxTokens: 4e5 }
5289
+ limits
5185
5290
  },
5186
5291
  {
5187
5292
  gateway: this.deps.gateway,
@@ -5223,12 +5328,17 @@ var RunCoordinator = class {
5223
5328
  selfReportedReason: result.selfReportedReason,
5224
5329
  patienceBudget: budgets[index],
5225
5330
  patienceState: result.patienceState,
5226
- limits: { maxActions: 40, maxElapsedMs: 15 * 6e4, maxTokens: 4e5 },
5331
+ limits,
5227
5332
  startedAt: result.startedAt,
5228
5333
  endedAt: result.endedAt,
5229
5334
  error: result.error ? { kind: "internal", message: result.error.message, retryable: result.error.retryable, occurredAt: result.endedAt } : null,
5230
5335
  contamination: null,
5231
5336
  momentCount: result.moments.length,
5337
+ // Carried straight from the loop rather than derived from `status`, which cannot say: a
5338
+ // clean finish and an exhausted action budget are both `partial`. MONETIZATION "Refunds"
5339
+ // bills the first and refunds the second whole, so the distinction has to survive the trip
5340
+ // into the persisted record.
5341
+ endReason: result.endReason,
5232
5342
  workerId: worker.id
5233
5343
  };
5234
5344
  this.sessionsByPersonaIndex.set(index, session);
@@ -6246,6 +6356,9 @@ function failureFrom(outcome) {
6246
6356
  }
6247
6357
  function describeOutcome(result) {
6248
6358
  if (result.status === "error") return "the browser stopped unexpectedly";
6359
+ if (result.endReason === "cap_actions") return "ran out of actions before finishing";
6360
+ if (result.endReason === "cap_elapsed") return "ran out of time before finishing";
6361
+ if (result.endReason === "cap_tokens") return "ran out of its token budget before finishing";
6249
6362
  if (result.selfReportedOutcome === "success") return "reached the goal";
6250
6363
  if (result.selfReportedOutcome === "abandoned") return result.selfReportedReason ?? "gave up";
6251
6364
  return result.selfReportedOutcome ?? "finished";
@@ -6639,6 +6752,10 @@ var StudyDesignProposalResponse = z23.object({
6639
6752
  // ../app/src/services/billing.ts
6640
6753
  init_src();
6641
6754
 
6755
+ // ../app/src/services/billing-outbox.ts
6756
+ init_src();
6757
+ var DAY_MS = 24 * 60 * 60 * 1e3;
6758
+
6642
6759
  // ../app/src/application.ts
6643
6760
  var PreflightFailedError = class extends Error {
6644
6761
  constructor(preflight2) {
@@ -6651,6 +6768,9 @@ var PreflightFailedError = class extends Error {
6651
6768
  preflight;
6652
6769
  };
6653
6770
 
6771
+ // ../app/src/services/billing-webhooks.ts
6772
+ init_src();
6773
+
6654
6774
  // ../app/src/services/export.ts
6655
6775
  var DEFAULT_MAX_EMBEDDED_BYTES = 24 * 1024 * 1024;
6656
6776
 
@@ -6875,6 +6995,8 @@ var MCP_TOOLS = [
6875
6995
  "propose_personas",
6876
6996
  "approve_personas",
6877
6997
  "run_preflight",
6998
+ "quote_study",
6999
+ "approve_quote",
6878
7000
  "start_study",
6879
7001
  "get_run_status",
6880
7002
  "cancel_run",
@@ -6890,6 +7012,12 @@ var MUTATING_TOOLS = {
6890
7012
  propose_personas: { mutates: false, capability: "study.write", idempotent: true },
6891
7013
  approve_personas: { mutates: true, capability: "study.write", idempotent: true },
6892
7014
  run_preflight: { mutates: false, capability: "study.write", idempotent: true },
7015
+ // BILLING_PLAN 3. Quoting is a builder operation and changes no money, so it is authorised the
7016
+ // same way `run_preflight` is. Approving the ceiling is what commits a run to spend, so it is
7017
+ // authorised as `run.start`: an operator can start runs and cannot manage billing, and requiring
7018
+ // `billing.manage` here would mean no operator could ever start a study.
7019
+ quote_study: { mutates: true, capability: "study.write", idempotent: false },
7020
+ approve_quote: { mutates: true, capability: "run.start", idempotent: true },
6893
7021
  start_study: { mutates: true, capability: "run.start", idempotent: false },
6894
7022
  get_run_status: { mutates: false, capability: "report.read", idempotent: true },
6895
7023
  cancel_run: { mutates: true, capability: "run.cancel", idempotent: true },
@@ -6937,8 +7065,25 @@ var StartStudyInput = z25.object({
6937
7065
  productId: z25.string(),
6938
7066
  /** SPEC FR-116: supplied by the client so a retry finds the run rather than making a second. */
6939
7067
  idempotencyKey: z25.string().min(1).max(120),
6940
- maxCostCents: z25.number().positive().nullable().default(null)
7068
+ maxCostCents: z25.number().positive().nullable().default(null),
7069
+ /**
7070
+ * BILLING_PLAN 3: the run `quote_study` priced and `approve_quote` authorised.
7071
+ *
7072
+ * Optional in the schema and required in effect: a start with no run id gets a fresh one, which
7073
+ * by construction carries no approved quote, and `@aloud/app` refuses it. Typed as optional so
7074
+ * the refusal comes from the one authorization path rather than from an argument parser here,
7075
+ * which is SPEC FR-110: two enforcement points is one too many.
7076
+ */
7077
+ runId: z25.string().optional()
6941
7078
  });
7079
+ var QuoteStudyInput = z25.object({
7080
+ studyId: z25.string(),
7081
+ /** Re-quotes an existing prospective run instead of pricing a new one. */
7082
+ runId: z25.string().optional(),
7083
+ /** Overrides the archetype prediction of moments per participant. */
7084
+ momentsEach: z25.number().int().min(1).max(400).optional()
7085
+ });
7086
+ var ApproveQuoteInput = z25.object({ runId: z25.string() });
6942
7087
  var RunIdInput = z25.object({ runId: z25.string() });
6943
7088
  var FindingInput = z25.object({ runId: z25.string(), findingId: z25.string() });
6944
7089
  var ShareLinkInput = z25.object({
@@ -7025,7 +7170,7 @@ async function handleTool(context, name, input, execute) {
7025
7170
  castComposition: draft.cast.compositionFloor,
7026
7171
  preflight: summarisePreflight(draft.preflight),
7027
7172
  estimateCents: app.estimateFor(draft.study),
7028
- note: "Nothing has run yet. Approve the cast, then start the study."
7173
+ note: "Nothing has run yet. Approve the cast, quote the study, get the ceiling approved, then start the run the quote named."
7029
7174
  });
7030
7175
  }
7031
7176
  case "get_product_profile": {
@@ -7077,6 +7222,50 @@ async function handleTool(context, name, input, execute) {
7077
7222
  const preflight2 = await app.preflight(actor, { workspaceId, studyId: parsed.studyId });
7078
7223
  return json(summarisePreflight(preflight2));
7079
7224
  }
7225
+ /**
7226
+ * BILLING_PLAN 3: "The MCP path enforces the same gate as the web path, through the one
7227
+ * authorization path in `@aloud/app`."
7228
+ *
7229
+ * That sentence is why this is three calls rather than one convenient one. An agent that could
7230
+ * start a study without a recorded agreement would be a way around the gate, and the whole
7231
+ * point of the gate is that a variable meter needs an agreement somebody actually made. So the
7232
+ * agent quotes, shows the human the range, gets the ceiling approved, and starts the run the
7233
+ * quote named.
7234
+ */
7235
+ case "quote_study": {
7236
+ const parsed = QuoteStudyInput.parse(input);
7237
+ const quoted = await app.quoteStudy(actor, { workspaceId, ...parsed });
7238
+ return json({
7239
+ quoteId: quoted.quote.id,
7240
+ // Carried into approve_quote and start_study. The agreement names the run, so the run has
7241
+ // to be named before it exists.
7242
+ runId: quoted.quote.runId,
7243
+ resource: RESOURCE_SCHEMES.run(quoted.quote.runId),
7244
+ rateCardVersion: quoted.quote.rateCardVersion,
7245
+ estimateLowCredits: quoted.quote.estimateLowCredits,
7246
+ estimateHighCredits: quoted.quote.estimateHighCredits,
7247
+ approvedCapCredits: quoted.quote.approvedCapCredits,
7248
+ // MONETIZATION.md, "Quoting a study": the range is what a customer is promised and the
7249
+ // basis is how much that promise is worth, so the two travel together.
7250
+ basis: quoted.basis,
7251
+ observations: quoted.observations,
7252
+ approved: false,
7253
+ note: "Nothing is committed. Moment count is not knowable in advance, so this is a range and a ceiling rather than a price. Show it to a person, then call approve_quote with this runId, then start_study with the same runId."
7254
+ });
7255
+ }
7256
+ case "approve_quote": {
7257
+ const parsed = ApproveQuoteInput.parse(input);
7258
+ const quote = await app.approveQuote(actor, { workspaceId, runId: parsed.runId });
7259
+ return json({
7260
+ quoteId: quote.id,
7261
+ runId: quote.runId,
7262
+ approvedCapCredits: quote.approvedCapCredits,
7263
+ approvedBy: quote.approvedBy,
7264
+ approvedAt: quote.approvedAt,
7265
+ rateCardVersion: quote.rateCardVersion,
7266
+ note: "This run may never spend more than the approved ceiling. It becomes the per-session action limit, so the cost control and the honest-abandonment guarantee are one mechanism."
7267
+ });
7268
+ }
7080
7269
  case "start_study": {
7081
7270
  const parsed = StartStudyInput.parse(input);
7082
7271
  let outcome;
@@ -7085,7 +7274,10 @@ async function handleTool(context, name, input, execute) {
7085
7274
  workspaceId,
7086
7275
  studyId: parsed.studyId,
7087
7276
  idempotencyKey: parsed.idempotencyKey,
7088
- maxCostCents: parsed.maxCostCents
7277
+ maxCostCents: parsed.maxCostCents,
7278
+ // BILLING_PLAN 3: the run the approved quote priced. Omitted, the gate in `@aloud/app`
7279
+ // refuses, which is the same refusal the web path gets.
7280
+ ...parsed.runId ? { runId: parsed.runId } : {}
7089
7281
  });
7090
7282
  } catch (error) {
7091
7283
  if (error instanceof PreflightFailedError) {
@@ -7101,13 +7293,13 @@ async function handleTool(context, name, input, execute) {
7101
7293
  }
7102
7294
  throw error;
7103
7295
  }
7104
- const { run, started, preflight: preflight2 } = outcome;
7105
- await execute?.(run.id, parsed.productId, started);
7296
+ const { run: run2, started, preflight: preflight2 } = outcome;
7297
+ await execute?.(run2.id, parsed.productId, started);
7106
7298
  return json({
7107
- runId: run.id,
7108
- resource: RESOURCE_SCHEMES.run(run.id),
7109
- webUrl: context.webBaseUrl ? `${context.webBaseUrl}/app/runs/${run.id}` : null,
7110
- status: run.status,
7299
+ runId: run2.id,
7300
+ resource: RESOURCE_SCHEMES.run(run2.id),
7301
+ webUrl: context.webBaseUrl ? `${context.webBaseUrl}/app/runs/${run2.id}` : null,
7302
+ status: run2.status,
7111
7303
  // SPEC FR-116: a retry with the same key reports that it found the existing run.
7112
7304
  alreadyRunning: !started,
7113
7305
  preflight: preflight2 ? summarisePreflight(preflight2) : null,
@@ -7135,8 +7327,8 @@ async function handleTool(context, name, input, execute) {
7135
7327
  }
7136
7328
  case "cancel_run": {
7137
7329
  const parsed = z25.object({ runId: z25.string(), reason: z25.string().optional() }).parse(input);
7138
- const run = await app.cancelRun(actor, { workspaceId, ...parsed });
7139
- return json({ runId: run.id, status: run.status });
7330
+ const run2 = await app.cancelRun(actor, { workspaceId, ...parsed });
7331
+ return json({ runId: run2.id, status: run2.status });
7140
7332
  }
7141
7333
  case "list_findings": {
7142
7334
  const parsed = RunIdInput.parse(input);
@@ -7238,7 +7430,9 @@ var TOOL_DESCRIPTIONS = {
7238
7430
  propose_personas: "Propose a cast of behaviourally distinct participants for an existing study, derived only from what a visitor to the site could see.",
7239
7431
  approve_personas: "Approve the cast a study will run with. Required before a study can start.",
7240
7432
  run_preflight: "Check a study is safe and ready to run. Names the exact failing check rather than a generic failure.",
7241
- start_study: "Start a study. Returns a run id immediately; the study runs in the background. Requires an idempotency key so a retry finds the existing run instead of starting a second.",
7433
+ quote_study: "Price a study before it runs. Moment count is not knowable in advance, so this returns an estimate range and a ceiling rather than a price, plus the runId the quote is recorded against. Show the range to a person before approving it.",
7434
+ approve_quote: "Record that the operator agreed to a study's ceiling. Required before the run can start, and it becomes the per-session action limit so the run can never exceed what was approved.",
7435
+ start_study: "Start a study. Returns a run id immediately; the study runs in the background. Requires an idempotency key so a retry finds the existing run instead of starting a second, and the runId from an approved quote.",
7242
7436
  get_run_status: "Read a run's current status, its participants, and its report if one exists yet.",
7243
7437
  cancel_run: "Stop a run. Browsers are closed and no further model spend happens.",
7244
7438
  list_findings: "List the findings from a completed run, with the participant count behind each one and the study's stated limitations.",
@@ -7299,11 +7493,18 @@ var TOOL_SHAPES = {
7299
7493
  },
7300
7494
  approve_personas: { studyId: z26.string(), cast: z26.array(z26.record(z26.string(), z26.unknown())) },
7301
7495
  run_preflight: { studyId: z26.string() },
7496
+ quote_study: {
7497
+ studyId: z26.string(),
7498
+ runId: z26.string().describe("Re-quote a prospective run instead of pricing a new one.").optional(),
7499
+ momentsEach: z26.number().int().min(1).max(400).describe("Override the predicted moments per participant.").optional()
7500
+ },
7501
+ approve_quote: { runId: z26.string().describe("The runId quote_study returned.") },
7302
7502
  start_study: {
7303
7503
  studyId: z26.string(),
7304
7504
  productId: z26.string(),
7305
7505
  idempotencyKey: z26.string().describe("Reuse this on a retry so it finds the run rather than starting a second."),
7306
- maxCostCents: z26.number().positive().nullable().optional()
7506
+ maxCostCents: z26.number().positive().nullable().optional(),
7507
+ runId: z26.string().describe("The runId from the approved quote. A run cannot start without one.").optional()
7307
7508
  },
7308
7509
  get_run_status: { runId: z26.string() },
7309
7510
  cancel_run: { runId: z26.string(), reason: z26.string().optional() },
@@ -7328,6 +7529,11 @@ function createMcpServer(options) {
7328
7529
  "not reports from real customers, and every response says how many participants hit a thing",
7329
7530
  "rather than what share of users would.",
7330
7531
  "",
7532
+ "A study costs money to run, so it cannot start until somebody has agreed to a ceiling.",
7533
+ "Call quote_study, show a person the range and the cap it returns, call approve_quote, then",
7534
+ "start_study with the same runId. The estimate is a range because how far a participant gets",
7535
+ "is the thing being measured, so there is no single price to quote.",
7536
+ "",
7331
7537
  "start_study returns immediately. Poll get_run_status; nothing is lost if this connection drops.",
7332
7538
  "",
7333
7539
  "When you are running beside a product repository, call upsert_product_context with derived",
@@ -7494,7 +7700,7 @@ async function readResource(context, parsed) {
7494
7700
  name: study.name,
7495
7701
  goal: study.goal,
7496
7702
  status: study.status,
7497
- runs: runs.map((run) => ({ runId: run.id, status: run.status, resource: RESOURCE_SCHEMES.run(run.id) }))
7703
+ runs: runs.map((run2) => ({ runId: run2.id, status: run2.status, resource: RESOURCE_SCHEMES.run(run2.id) }))
7498
7704
  },
7499
7705
  null,
7500
7706
  2
@@ -7737,32 +7943,47 @@ async function setup() {
7737
7943
  const installed = onPath("aloud");
7738
7944
  const latest = await latestVersion();
7739
7945
  const stale = latest !== null && latest !== RUNNER_VERSION;
7946
+ const signedIn = await signedInState(credentials);
7740
7947
  const out = (line = "") => process.stdout.write(line + "\n");
7741
7948
  out();
7742
7949
  out("Aloud runner setup. You are looking at the state of this machine.");
7743
7950
  out();
7744
7951
  out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
7745
7952
  out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
7746
- out(` signed in ${credentials ? credentials.runnerName : "no"}`);
7953
+ out(
7954
+ ` signed in ${signedIn.state === "ok" ? signedIn.name : signedIn.state === "revoked" ? `no. The saved token for ${credentials?.runnerName ?? "this machine"} was revoked` : signedIn.state === "unreachable" ? `cannot tell, ${signedIn.server} did not answer` : "no"}`
7955
+ );
7747
7956
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
7748
7957
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
7749
7958
  out();
7959
+ if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
7750
7960
  const steps = [];
7751
7961
  if (!installed) {
7752
- steps.push("npm install -g @aloud/runner");
7962
+ steps.push(["npm install -g @aloud/runner"]);
7753
7963
  } else if (stale) {
7754
- steps.push(`npm install -g @aloud/runner@latest # ${RUNNER_VERSION} is installed, ${latest} is out`);
7964
+ steps.push([`npm install -g @aloud/runner@latest`, `${RUNNER_VERSION} is installed, ${latest} is out.`]);
7755
7965
  }
7756
- if (!credentials) {
7757
- steps.push(
7758
- "aloud login # needs a terminal. If you are an agent, hand this step to the person:"
7759
- );
7760
- steps.push(
7761
- " # they run it themselves and paste the token at the prompt."
7762
- );
7966
+ if (signedIn.state === "revoked") {
7967
+ steps.push([
7968
+ `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
7969
+ "The saved one was revoked and cannot be reused."
7970
+ ]);
7971
+ }
7972
+ if (signedIn.state === "none" || signedIn.state === "revoked") {
7973
+ steps.push([
7974
+ "aloud login",
7975
+ "Needs a terminal. If you are an agent, hand this step to the person: they run it",
7976
+ "themselves and paste the token at the prompt. Do not ask them to paste it to you."
7977
+ ]);
7763
7978
  }
7764
7979
  if (!running) {
7765
- steps.push("aloud start # in a background shell. It never exits.");
7980
+ steps.push(["aloud start", "In a background shell. It never exits, so do not block on it."]);
7981
+ }
7982
+ if (steps.length === 0 && signedIn.state === "unreachable") {
7983
+ out(`Cannot reach ${signedIn.server}, so there is nothing useful to say about what is left.`);
7984
+ out("Check the connection and run this again.");
7985
+ out();
7986
+ return 1;
7766
7987
  }
7767
7988
  if (steps.length === 0) {
7768
7989
  out("Nothing to do. This machine is set up and waiting for studies.");
@@ -7771,8 +7992,12 @@ async function setup() {
7771
7992
  }
7772
7993
  out("Do these, in order:");
7773
7994
  out();
7774
- for (const [index, step] of steps.entries()) out(` ${index + 1}. ${step}`);
7775
- out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
7995
+ for (const [index, [command, ...notes]] of steps.entries()) {
7996
+ out(` ${index + 1}. ${command}`);
7997
+ for (const note of notes) out(` ${note}`);
7998
+ }
7999
+ out(` ${steps.length + 1}. aloud status`);
8000
+ out(" Exits non-zero until all of the above are true.");
7776
8001
  out();
7777
8002
  out("Rules, if you are an agent doing this:");
7778
8003
  out();
@@ -7796,6 +8021,105 @@ async function setup() {
7796
8021
  }
7797
8022
  return 1;
7798
8023
  }
8024
+ async function interactiveSetup(state) {
8025
+ const out = (line = "") => process.stdout.write(line + "\n");
8026
+ const server = state.credentials?.server ?? DEFAULT_SERVER;
8027
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8028
+ try {
8029
+ if (!state.installed || state.stale) {
8030
+ const what = state.installed ? `Update to ${state.latest}` : "Install it globally";
8031
+ if (await confirm(rl, `${what} with npm?`)) {
8032
+ const ok = await run("npm", ["install", "-g", "@aloud/runner@latest"], out);
8033
+ if (!ok) {
8034
+ out("");
8035
+ out("That install did not work. If it asked for permissions, do not use sudo:");
8036
+ out("npm's global prefix belongs to you or it does not, and sudo papers over the wrong one.");
8037
+ return 1;
8038
+ }
8039
+ }
8040
+ }
8041
+ if (state.signedIn.state !== "ok") {
8042
+ out("");
8043
+ if (state.signedIn.state === "revoked") {
8044
+ out(`The token saved here was revoked, so this machine needs a new one.`);
8045
+ }
8046
+ out(`Create a token at ${server}/app/settings/runners`);
8047
+ out("It is shown once. Copy it, then paste it below.");
8048
+ out("");
8049
+ const token = (await rl.question("Token: ")).trim();
8050
+ rl.close();
8051
+ const code = await login(["--token", token, "--server", server]);
8052
+ if (code !== 0) return code;
8053
+ } else {
8054
+ rl.close();
8055
+ }
8056
+ if (!state.running) {
8057
+ const second = createInterface({ input: process.stdin, output: process.stdout });
8058
+ const start2 = await confirm(second, "Start the runner now, in the background?");
8059
+ second.close();
8060
+ if (!start2) {
8061
+ out("");
8062
+ out("Start it when you are ready, and leave it running: aloud start");
8063
+ return 1;
8064
+ }
8065
+ return startDetached(out);
8066
+ }
8067
+ out("");
8068
+ out("Set up. This machine is waiting for studies.");
8069
+ out("");
8070
+ return 0;
8071
+ } finally {
8072
+ rl.close();
8073
+ }
8074
+ }
8075
+ async function confirm(rl, question) {
8076
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
8077
+ return answer === "" || answer === "y" || answer === "yes";
8078
+ }
8079
+ async function run(command, args, out) {
8080
+ out("");
8081
+ out(` ${command} ${args.join(" ")}`);
8082
+ return new Promise((resolve) => {
8083
+ const child = spawn2(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
8084
+ child.stdout?.on("data", (chunk) => out(" " + chunk.toString("utf8").trimEnd()));
8085
+ child.stderr?.on("data", (chunk) => out(" " + chunk.toString("utf8").trimEnd()));
8086
+ child.on("error", () => resolve(false));
8087
+ child.on("close", (code) => resolve(code === 0));
8088
+ });
8089
+ }
8090
+ async function startDetached(out) {
8091
+ const log = join5(dirname4(credentialsPath()), "runner.log");
8092
+ await mkdir4(dirname4(log), { recursive: true, mode: 448 });
8093
+ const handle = openSync(log, "a");
8094
+ const child = spawn2(process.execPath, [process.argv[1] ?? "", "start"], {
8095
+ detached: true,
8096
+ stdio: ["ignore", handle, handle]
8097
+ });
8098
+ child.unref();
8099
+ out("");
8100
+ out(`Started in the background, pid ${child.pid}.`);
8101
+ out(` Output ${log}`);
8102
+ out(" Check it aloud status");
8103
+ out(` Stop it kill ${child.pid}`);
8104
+ out("");
8105
+ out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
8106
+ out("");
8107
+ return 0;
8108
+ }
8109
+ async function signedInState(credentials) {
8110
+ if (!credentials) return { state: "none" };
8111
+ try {
8112
+ const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
8113
+ headers: { authorization: `Bearer ${credentials.token}` },
8114
+ signal: AbortSignal.timeout(5e3)
8115
+ });
8116
+ if (response.status === 401 || response.status === 403) return { state: "revoked" };
8117
+ if (!response.ok) return { state: "unreachable", server: credentials.server };
8118
+ return { state: "ok", name: credentials.runnerName };
8119
+ } catch {
8120
+ return { state: "unreachable", server: credentials.server };
8121
+ }
8122
+ }
7799
8123
  async function npmPrefix() {
7800
8124
  const path = await new Promise((resolve) => {
7801
8125
  const child = spawn2("npm", ["config", "get", "prefix"], { stdio: ["ignore", "pipe", "ignore"] });