@gr8ful/spf 0.9.2 → 0.10.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 (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -408,6 +408,173 @@ export declare const PromptEngineeringSchema: v.ObjectSchema<{
408
408
  export type PromptEngineering = v.InferOutput<typeof PromptEngineeringSchema>;
409
409
  export declare const ThinkingLevelSchema: v.PicklistSchema<["off", "minimal", "low", "medium", "high", "xhigh", "max"], undefined>;
410
410
  export type ThinkingLevel = v.InferOutput<typeof ThinkingLevelSchema>;
411
+ export declare const SandboxBackendSchema: v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>;
412
+ export type SandboxBackend = v.InferOutput<typeof SandboxBackendSchema>;
413
+ /**
414
+ * "agent" (the DEFAULT) — one sandbox per (Run, agent), so a container's
415
+ * process env (fixed at create time) is built from exactly one agent's
416
+ * allowlist. "run" — one sandbox for the whole Run; admitted only when
417
+ * every required agent resolves to an IDENTICAL env spec, enforced by
418
+ * `agents.ts`'s `validateSandboxRunScope`.
419
+ */
420
+ export declare const SandboxScopeSchema: v.PicklistSchema<["run", "agent"], undefined>;
421
+ export type SandboxScope = v.InferOutput<typeof SandboxScopeSchema>;
422
+ export declare const SandboxEgressSchema: v.ObjectSchema<{
423
+ readonly default: v.OptionalSchema<v.PicklistSchema<["deny", "allow"], undefined>, "deny">;
424
+ readonly allow: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
425
+ }, undefined>;
426
+ export type SandboxEgressConfig = v.InferOutput<typeof SandboxEgressSchema>;
427
+ /**
428
+ * `max_mirror_bytes` caps the handoff mirror (§5.3 point 2), EACH direction,
429
+ * per file. `max_patch_bytes`/`max_seed_bytes` cap the code-plane transport
430
+ * (§5.2). Exceeding any of them FAILS the phase; none of them ever truncate.
431
+ */
432
+ export declare const SandboxTransportSchema: v.ObjectSchema<{
433
+ readonly max_patch_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 8388608>;
434
+ readonly max_seed_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 134217728>;
435
+ readonly max_mirror_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 4194304>;
436
+ readonly mirror: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
437
+ }, undefined>;
438
+ export type SandboxTransportConfig = v.InferOutput<typeof SandboxTransportSchema>;
439
+ /**
440
+ * A plain string, not a picklist — a future broker (design §6.3, e.g.
441
+ * `openrouter`) registers itself in code (`sandbox.ts`'s
442
+ * `KNOWN_CREDENTIAL_BROKER_IDS`), so accepting new ids here must not also
443
+ * require a schema change in lockstep. "static" is the only broker
444
+ * REGISTERED in this build (PR A ships none at all — see sandbox.ts); an
445
+ * unknown name is a `validateSandboxConfig` failure (agents.ts), never a
446
+ * silent fallback to "static" — see design §6.1.
447
+ */
448
+ export declare const SandboxCredentialsSchema: v.ObjectSchema<{
449
+ readonly broker: v.OptionalSchema<v.StringSchema<undefined>, "static">;
450
+ }, undefined>;
451
+ export type SandboxCredentialsConfig = v.InferOutput<typeof SandboxCredentialsSchema>;
452
+ export declare const SandboxOpenSandboxSchema: v.ObjectSchema<{
453
+ readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "http://127.0.0.1:8090">;
454
+ readonly api_key_env: v.OptionalSchema<v.StringSchema<undefined>, "OPENSANDBOX_API_KEY">;
455
+ readonly use_server_proxy: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
456
+ readonly metadata_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
457
+ }, undefined>;
458
+ export type SandboxOpenSandboxConfig = v.InferOutput<typeof SandboxOpenSandboxSchema>;
459
+ export declare const SandboxCloudflareSchema: v.ObjectSchema<{
460
+ readonly bridge_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
461
+ readonly api_token_env: v.OptionalSchema<v.StringSchema<undefined>, "CLOUDFLARE_API_TOKEN">;
462
+ readonly sandbox_name: v.OptionalSchema<v.StringSchema<undefined>, "">;
463
+ }, undefined>;
464
+ export type SandboxCloudflareConfig = v.InferOutput<typeof SandboxCloudflareSchema>;
465
+ export declare const SandboxConfigSchema: v.ObjectSchema<{
466
+ readonly backend: v.OptionalSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, "local">;
467
+ readonly scope: v.OptionalSchema<v.PicklistSchema<["run", "agent"], undefined>, "agent">;
468
+ readonly workspace_dir: v.OptionalSchema<v.StringSchema<undefined>, "/workspace">;
469
+ readonly handoff_dir: v.OptionalSchema<v.StringSchema<undefined>, "/spf/handoff">;
470
+ readonly scratch_dir: v.OptionalSchema<v.StringSchema<undefined>, "/spf/tmp">;
471
+ readonly image: v.OptionalSchema<v.StringSchema<undefined>, "">;
472
+ readonly setup: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
473
+ readonly request_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 960>;
474
+ readonly lifetime_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 3600>;
475
+ readonly max_total_lifetime_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 21600>;
476
+ readonly exec_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 900>;
477
+ readonly env_allowlist: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
478
+ readonly credentials: v.OptionalSchema<v.ObjectSchema<{
479
+ readonly broker: v.OptionalSchema<v.StringSchema<undefined>, "static">;
480
+ }, undefined>, () => {
481
+ broker: string;
482
+ }>;
483
+ readonly egress: v.OptionalSchema<v.ObjectSchema<{
484
+ readonly default: v.OptionalSchema<v.PicklistSchema<["deny", "allow"], undefined>, "deny">;
485
+ readonly allow: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
486
+ }, undefined>, () => {
487
+ default: "allow" | "deny";
488
+ allow: string[];
489
+ }>;
490
+ readonly transport: v.OptionalSchema<v.ObjectSchema<{
491
+ readonly max_patch_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 8388608>;
492
+ readonly max_seed_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 134217728>;
493
+ readonly max_mirror_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 4194304>;
494
+ readonly mirror: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
495
+ }, undefined>, () => {
496
+ max_patch_bytes: number;
497
+ max_seed_bytes: number;
498
+ max_mirror_bytes: number;
499
+ mirror: string[];
500
+ }>;
501
+ readonly fanout: v.OptionalSchema<v.PicklistSchema<["worktree", "sandbox"], undefined>, "worktree">;
502
+ readonly opensandbox: v.OptionalSchema<v.ObjectSchema<{
503
+ readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "http://127.0.0.1:8090">;
504
+ readonly api_key_env: v.OptionalSchema<v.StringSchema<undefined>, "OPENSANDBOX_API_KEY">;
505
+ readonly use_server_proxy: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
506
+ readonly metadata_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
507
+ }, undefined>, () => {
508
+ base_url: string;
509
+ api_key_env: string;
510
+ use_server_proxy: boolean;
511
+ metadata_prefix: string;
512
+ }>;
513
+ readonly cloudflare: v.OptionalSchema<v.ObjectSchema<{
514
+ readonly bridge_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
515
+ readonly api_token_env: v.OptionalSchema<v.StringSchema<undefined>, "CLOUDFLARE_API_TOKEN">;
516
+ readonly sandbox_name: v.OptionalSchema<v.StringSchema<undefined>, "">;
517
+ }, undefined>, () => {
518
+ bridge_url: string;
519
+ api_token_env: string;
520
+ sandbox_name: string;
521
+ }>;
522
+ }, undefined>;
523
+ export type SandboxConfig = v.InferOutput<typeof SandboxConfigSchema>;
524
+ /**
525
+ * Pure, sync, derived from config + the run — see `agents.ts`'s
526
+ * `sandboxSpecFor`. Carried on `AgentRequest.sandbox` below. Lives HERE
527
+ * (not in `core/sandbox.ts`) so this leaf module keeps importing nothing
528
+ * but valibot, while fourteen-plus other core modules import it — see
529
+ * `core/sandbox.ts`'s module comment for the full placement rationale
530
+ * (SPF #15 design doc §4.3's placement table).
531
+ */
532
+ export interface SandboxSpec {
533
+ backend: SandboxBackend;
534
+ /** Identity available at spec-construction time — BEFORE agentSessionId(). */
535
+ adw_id: string;
536
+ agent: string;
537
+ /**
538
+ * Lease identity. `<adw_id>/<agent>` when scope=agent (the DEFAULT),
539
+ * `<adw_id>` when scope=run. INVARIANT: the key is at least as
540
+ * fine-grained as `env` below, because a container's env is fixed at
541
+ * create time and a cache hit never re-creates.
542
+ */
543
+ lease_key: string;
544
+ scope: SandboxScope;
545
+ /** Host side: the tree that is the record of truth. run.repo_root. */
546
+ host_root: string;
547
+ /** Sandbox side: workspace_dir. Equals host_root when backend === "local" (never constructed then — see sandboxSpecFor). */
548
+ workspace_dir: string;
549
+ /**
550
+ * Host + sandbox paths for the handoff plane. INVARIANT (enforced by
551
+ * validateSandboxConfig): handoff_sandbox is NOT under workspace_dir.
552
+ * SHAPE INVARIANT: handoff_sandbox is
553
+ * `<handoff_dir>/sessions/<adw_id>/context_handoff`, mirroring
554
+ * handoff_host = `<data_dir>/sessions/<adw_id>/context_handoff`
555
+ * (`runner.ts`) — two shipped prompts parse `<adw_id>` out of this shape.
556
+ */
557
+ handoff_host: string;
558
+ handoff_sandbox: string;
559
+ /**
560
+ * The TRANSPORT'S OWN scratch plane inside the sandbox: seed.tar,
561
+ * seed.patch[.b64], out.patch[.b64]. SAME INVARIANT, same enforcement,
562
+ * same reason as handoff_sandbox: NOT under workspace_dir.
563
+ */
564
+ scratch_dir: string;
565
+ /** THIS AGENT's resolved keys: sandbox.env_allowlist ∩ (agent.env_allowlist ?? all). Already filtered; may be {}. */
566
+ env: Record<string, string>;
567
+ image: string;
568
+ setup: string[];
569
+ egress: SandboxEgressConfig;
570
+ lifetime_seconds: number;
571
+ max_total_lifetime_seconds: number;
572
+ request_timeout_seconds: number;
573
+ exec_timeout_seconds: number;
574
+ transport: SandboxTransportConfig;
575
+ opensandbox: SandboxOpenSandboxConfig;
576
+ cloudflare: SandboxCloudflareConfig;
577
+ }
411
578
  export declare const AgentConfigSchema: v.ObjectSchema<{
412
579
  readonly name: v.StringSchema<undefined>;
413
580
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -423,6 +590,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
423
590
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
424
591
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
425
592
  readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
593
+ readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
426
594
  }, undefined>;
427
595
  export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
428
596
  export declare const ConfigDefaultsSchema: v.ObjectSchema<{
@@ -612,6 +780,37 @@ export declare const WatchRefineConfigSchema: v.ObjectSchema<{
612
780
  readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
613
781
  }, undefined>;
614
782
  export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
783
+ /**
784
+ * Best-of-N per claimed issue — `core/fanout.ts`'s proven machinery pointed at
785
+ * watch's per-issue dispatch. `n: 1` (the DEFAULT) leaves the daemon's
786
+ * behaviour untouched: `core/watch.ts`'s `runIssue` takes its existing
787
+ * single-dispatch path, same adw_id (`issue-<id>`), same one worktree, same
788
+ * everything. (`spf doctor` gains one informational line either way — see
789
+ * that command; nothing about a running daemon changes.)
790
+ *
791
+ * `concurrency` is DELIBERATELY NOT `watch.concurrency`, which counts ISSUES in
792
+ * flight (`core/watch.ts`'s `state.inflight`, a set of issue ids). This one
793
+ * counts ATTEMPTS in flight WITHIN one issue. The two multiply — see
794
+ * `spf doctor`'s `watch.fanout budget` line, which prints the product rather
795
+ * than leaving an operator to discover it on an invoice. Same "each axis of
796
+ * parallelism gets its own named budget" rule `refine.concurrency` already
797
+ * follows (see `WatchRunState.refining`'s doc comment).
798
+ *
799
+ * NOTE that `concurrency` bounds attempts IN FLIGHT, not worktrees ON DISK: a
800
+ * successful attempt's tree is KEPT until every sibling has settled (it is a
801
+ * selection candidate), so peak disk is `n` trees per issue regardless of this
802
+ * number. Doctor prints both.
803
+ *
804
+ * The `n` ceiling is 8, the twin of `MAX_N` in `cli/commands/fanout.ts` — kept
805
+ * as a literal here rather than imported because this module imports nothing
806
+ * but valibot, on purpose (see `SandboxSpec`'s placement note). A daemon typing
807
+ * a bigger number is strictly worse than an interactive command doing so.
808
+ */
809
+ export declare const WatchFanoutConfigSchema: v.ObjectSchema<{
810
+ readonly n: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 8, undefined>]>, 1>;
811
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
812
+ }, undefined>;
813
+ export type WatchFanoutConfig = v.InferOutput<typeof WatchFanoutConfigSchema>;
615
814
  export declare const WatchConfigSchema: v.ObjectSchema<{
616
815
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
617
816
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
@@ -674,6 +873,13 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
674
873
  chain: string;
675
874
  concurrency: number;
676
875
  }>;
876
+ readonly fanout: v.OptionalSchema<v.ObjectSchema<{
877
+ readonly n: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 8, undefined>]>, 1>;
878
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
879
+ }, undefined>, () => {
880
+ n: number;
881
+ concurrency: number;
882
+ }>;
677
883
  }, undefined>;
678
884
  export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
679
885
  /**
@@ -904,6 +1110,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
904
1110
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
905
1111
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
906
1112
  readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
1113
+ readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
907
1114
  }, undefined>, undefined>, () => never[]>;
908
1115
  readonly quality: v.OptionalSchema<v.ObjectSchema<{
909
1116
  readonly checks: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
@@ -988,6 +1195,13 @@ export declare const SFConfigSchema: v.ObjectSchema<{
988
1195
  chain: string;
989
1196
  concurrency: number;
990
1197
  }>;
1198
+ readonly fanout: v.OptionalSchema<v.ObjectSchema<{
1199
+ readonly n: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 8, undefined>]>, 1>;
1200
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
1201
+ }, undefined>, () => {
1202
+ n: number;
1203
+ concurrency: number;
1204
+ }>;
991
1205
  }, undefined>, () => {
992
1206
  issue_provider: "github" | "jira";
993
1207
  code_host: "bitbucket" | "github";
@@ -1017,6 +1231,10 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1017
1231
  chain: string;
1018
1232
  concurrency: number;
1019
1233
  };
1234
+ fanout: {
1235
+ n: number;
1236
+ concurrency: number;
1237
+ };
1020
1238
  }>;
1021
1239
  readonly notifications: v.OptionalSchema<v.ObjectSchema<{
1022
1240
  readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, "off">;
@@ -1063,6 +1281,102 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1063
1281
  [x: string]: string;
1064
1282
  };
1065
1283
  }>;
1284
+ readonly sandbox: v.OptionalSchema<v.ObjectSchema<{
1285
+ readonly backend: v.OptionalSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, "local">;
1286
+ readonly scope: v.OptionalSchema<v.PicklistSchema<["run", "agent"], undefined>, "agent">;
1287
+ readonly workspace_dir: v.OptionalSchema<v.StringSchema<undefined>, "/workspace">;
1288
+ readonly handoff_dir: v.OptionalSchema<v.StringSchema<undefined>, "/spf/handoff">;
1289
+ readonly scratch_dir: v.OptionalSchema<v.StringSchema<undefined>, "/spf/tmp">;
1290
+ readonly image: v.OptionalSchema<v.StringSchema<undefined>, "">;
1291
+ readonly setup: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
1292
+ readonly request_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 960>;
1293
+ readonly lifetime_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 3600>;
1294
+ readonly max_total_lifetime_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 21600>;
1295
+ readonly exec_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 900>;
1296
+ readonly env_allowlist: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
1297
+ readonly credentials: v.OptionalSchema<v.ObjectSchema<{
1298
+ readonly broker: v.OptionalSchema<v.StringSchema<undefined>, "static">;
1299
+ }, undefined>, () => {
1300
+ broker: string;
1301
+ }>;
1302
+ readonly egress: v.OptionalSchema<v.ObjectSchema<{
1303
+ readonly default: v.OptionalSchema<v.PicklistSchema<["deny", "allow"], undefined>, "deny">;
1304
+ readonly allow: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
1305
+ }, undefined>, () => {
1306
+ default: "allow" | "deny";
1307
+ allow: string[];
1308
+ }>;
1309
+ readonly transport: v.OptionalSchema<v.ObjectSchema<{
1310
+ readonly max_patch_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 8388608>;
1311
+ readonly max_seed_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 134217728>;
1312
+ readonly max_mirror_bytes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 4194304>;
1313
+ readonly mirror: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
1314
+ }, undefined>, () => {
1315
+ max_patch_bytes: number;
1316
+ max_seed_bytes: number;
1317
+ max_mirror_bytes: number;
1318
+ mirror: string[];
1319
+ }>;
1320
+ readonly fanout: v.OptionalSchema<v.PicklistSchema<["worktree", "sandbox"], undefined>, "worktree">;
1321
+ readonly opensandbox: v.OptionalSchema<v.ObjectSchema<{
1322
+ readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "http://127.0.0.1:8090">;
1323
+ readonly api_key_env: v.OptionalSchema<v.StringSchema<undefined>, "OPENSANDBOX_API_KEY">;
1324
+ readonly use_server_proxy: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
1325
+ readonly metadata_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
1326
+ }, undefined>, () => {
1327
+ base_url: string;
1328
+ api_key_env: string;
1329
+ use_server_proxy: boolean;
1330
+ metadata_prefix: string;
1331
+ }>;
1332
+ readonly cloudflare: v.OptionalSchema<v.ObjectSchema<{
1333
+ readonly bridge_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
1334
+ readonly api_token_env: v.OptionalSchema<v.StringSchema<undefined>, "CLOUDFLARE_API_TOKEN">;
1335
+ readonly sandbox_name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1336
+ }, undefined>, () => {
1337
+ bridge_url: string;
1338
+ api_token_env: string;
1339
+ sandbox_name: string;
1340
+ }>;
1341
+ }, undefined>, () => {
1342
+ backend: "cloudflare" | "local" | "opensandbox";
1343
+ scope: "agent" | "run";
1344
+ workspace_dir: string;
1345
+ handoff_dir: string;
1346
+ scratch_dir: string;
1347
+ image: string;
1348
+ setup: string[];
1349
+ request_timeout_seconds: number;
1350
+ lifetime_seconds: number;
1351
+ max_total_lifetime_seconds: number;
1352
+ exec_timeout_seconds: number;
1353
+ env_allowlist: string[];
1354
+ credentials: {
1355
+ broker: string;
1356
+ };
1357
+ egress: {
1358
+ default: "allow" | "deny";
1359
+ allow: string[];
1360
+ };
1361
+ transport: {
1362
+ max_patch_bytes: number;
1363
+ max_seed_bytes: number;
1364
+ max_mirror_bytes: number;
1365
+ mirror: string[];
1366
+ };
1367
+ fanout: "sandbox" | "worktree";
1368
+ opensandbox: {
1369
+ base_url: string;
1370
+ api_key_env: string;
1371
+ use_server_proxy: boolean;
1372
+ metadata_prefix: string;
1373
+ };
1374
+ cloudflare: {
1375
+ bridge_url: string;
1376
+ api_token_env: string;
1377
+ sandbox_name: string;
1378
+ };
1379
+ }>;
1066
1380
  }, undefined>;
1067
1381
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
1068
1382
  /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
@@ -1116,6 +1430,8 @@ export interface AgentRequest {
1116
1430
  cwd: string;
1117
1431
  flue_db_path: string;
1118
1432
  env?: Record<string, string>;
1433
+ /** Absent (the default) => local(), byte-identical to before this field existed. See sandbox.ts. */
1434
+ sandbox?: SandboxSpec;
1119
1435
  }
1120
1436
  /**
1121
1437
  * Tokens and the dollars they cost, per component, summed over a call.
@@ -302,6 +302,110 @@ export const PromptEngineeringSchema = v.object({
302
302
  user: v.string(), // path to user.md
303
303
  });
304
304
  export const ThinkingLevelSchema = v.picklist(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
305
+ // ── Sandbox (SPF #15 — remote sandbox backends) ─────────────────────────────
306
+ //
307
+ // Absent `sandbox:` block entirely => backend: "local", byte-identical to
308
+ // SPF before this feature existed — see agents.ts's `mergeRawConfig` (the
309
+ // `sandbox` line) and `SFConfigSchema` below, whose own `v.optional` default
310
+ // makes `cfg.sandbox` always defined even with no config file naming it. See
311
+ // `assets/skill/references/config.md`'s `### sandbox` section for the
312
+ // field-by-field rationale; this schema only encodes shape and defaults.
313
+ export const SandboxBackendSchema = v.picklist(["local", "opensandbox", "cloudflare"]);
314
+ /**
315
+ * "agent" (the DEFAULT) — one sandbox per (Run, agent), so a container's
316
+ * process env (fixed at create time) is built from exactly one agent's
317
+ * allowlist. "run" — one sandbox for the whole Run; admitted only when
318
+ * every required agent resolves to an IDENTICAL env spec, enforced by
319
+ * `agents.ts`'s `validateSandboxRunScope`.
320
+ */
321
+ export const SandboxScopeSchema = v.picklist(["run", "agent"]);
322
+ export const SandboxEgressSchema = v.object({
323
+ default: v.optional(v.picklist(["deny", "allow"]), "deny"),
324
+ allow: v.optional(v.array(v.string()), () => []),
325
+ });
326
+ /**
327
+ * `max_mirror_bytes` caps the handoff mirror (§5.3 point 2), EACH direction,
328
+ * per file. `max_patch_bytes`/`max_seed_bytes` cap the code-plane transport
329
+ * (§5.2). Exceeding any of them FAILS the phase; none of them ever truncate.
330
+ */
331
+ export const SandboxTransportSchema = v.object({
332
+ max_patch_bytes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 8_388_608),
333
+ max_seed_bytes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 134_217_728),
334
+ max_mirror_bytes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 4_194_304),
335
+ // Extra repo-relative paths to sync out of the sandbox that git cannot
336
+ // carry (gitignored artifacts you want back).
337
+ mirror: v.optional(v.array(v.string()), () => []),
338
+ });
339
+ /**
340
+ * A plain string, not a picklist — a future broker (design §6.3, e.g.
341
+ * `openrouter`) registers itself in code (`sandbox.ts`'s
342
+ * `KNOWN_CREDENTIAL_BROKER_IDS`), so accepting new ids here must not also
343
+ * require a schema change in lockstep. "static" is the only broker
344
+ * REGISTERED in this build (PR A ships none at all — see sandbox.ts); an
345
+ * unknown name is a `validateSandboxConfig` failure (agents.ts), never a
346
+ * silent fallback to "static" — see design §6.1.
347
+ */
348
+ export const SandboxCredentialsSchema = v.object({
349
+ broker: v.optional(v.string(), "static"),
350
+ });
351
+ export const SandboxOpenSandboxSchema = v.object({
352
+ base_url: v.optional(v.string(), "http://127.0.0.1:8090"),
353
+ // env KEY NAME, never the key itself — matches the GITHUB_TOKEN style.
354
+ api_key_env: v.optional(v.string(), "OPENSANDBOX_API_KEY"),
355
+ use_server_proxy: v.optional(v.boolean(), false),
356
+ metadata_prefix: v.optional(v.string(), "spf"),
357
+ });
358
+ export const SandboxCloudflareSchema = v.object({
359
+ bridge_url: v.optional(v.string(), ""), // a deployed @cloudflare/sandbox bridge
360
+ api_token_env: v.optional(v.string(), "CLOUDFLARE_API_TOKEN"),
361
+ sandbox_name: v.optional(v.string(), ""), // optional stable name; default derives from session id
362
+ });
363
+ export const SandboxConfigSchema = v.object({
364
+ backend: v.optional(SandboxBackendSchema, "local"),
365
+ scope: v.optional(SandboxScopeSchema, "agent"),
366
+ workspace_dir: v.optional(v.string(), "/workspace"), // absolute path INSIDE the sandbox
367
+ // The ROOT of the handoff plane inside the sandbox. The per-run dir the
368
+ // prompts actually see is DERIVED from it: `<handoff_dir>/sessions/<adw_id>/
369
+ // context_handoff`, mirroring `handoff_host` (`runner.ts`'s
370
+ // `context_handoff_dir`) — see `SandboxSpec.handoff_sandbox` below. MUST
371
+ // NOT be under `workspace_dir` — enforced by `validateSandboxConfig`.
372
+ handoff_dir: v.optional(v.string(), "/spf/handoff"),
373
+ // The TRANSPORT'S OWN scratch plane (seed.tar, seed.patch[.b64],
374
+ // out.patch[.b64]) — same "not under workspace_dir" rule, same reason,
375
+ // same enforcement as handoff_dir.
376
+ scratch_dir: v.optional(v.string(), "/spf/tmp"),
377
+ // opensandbox only. NO DEFAULT: `validateSandboxConfig` rejects an empty
378
+ // `image` when the resolved backend is "opensandbox" — it MUST contain
379
+ // git + tar + base64 (the transport shells out to all three).
380
+ image: v.optional(v.string(), ""),
381
+ setup: v.optional(v.array(v.string()), () => []), // shell commands run ONCE per sandbox, after seeding
382
+ // ConnectionConfig.requestTimeoutSeconds — the SDK's CLIENT-SIDE HTTP
383
+ // timeout, applied to every control-plane call. NOT the bound on a long
384
+ // agent command (exec is driven with streaming handlers) — see sandbox.ts.
385
+ request_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 960),
386
+ lifetime_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 3_600), // provider-side expiry, renewed while a lease is live
387
+ max_total_lifetime_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 21_600), // HARD ceiling from creation; renew refuses past it
388
+ // The DEFAULT per-command deadline, applied only when the caller supplies
389
+ // none. NOT a ceiling — a caller-supplied timeoutMs passes through
390
+ // unmodified, exactly as it does on backend: local. See sandbox.ts.
391
+ exec_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 900),
392
+ // Credentials handed to the sandbox process environment at create time.
393
+ // DEFAULT IS EMPTY — deliberately NOT the operator env local() gets today.
394
+ // The set that actually reaches a given container is the intersection of
395
+ // this list with THAT AGENT's own env_allowlist (unset = no further
396
+ // narrowing) — see agents.ts's sandboxSpecFor.
397
+ env_allowlist: v.optional(v.array(v.string()), () => []),
398
+ credentials: v.optional(SandboxCredentialsSchema, () => v.parse(SandboxCredentialsSchema, {})),
399
+ egress: v.optional(SandboxEgressSchema, () => v.parse(SandboxEgressSchema, {})),
400
+ transport: v.optional(SandboxTransportSchema, () => v.parse(SandboxTransportSchema, {})),
401
+ // worktree (default, honored) | sandbox (fan-out THROUGH sandboxes — PR B).
402
+ // Parsed and schema-valid here in PR A; agents.ts's validateSandboxConfig
403
+ // hard-rejects "sandbox" until PR B lands, naming the key — never a silent
404
+ // degrade back to worktrees.
405
+ fanout: v.optional(v.picklist(["worktree", "sandbox"]), "worktree"),
406
+ opensandbox: v.optional(SandboxOpenSandboxSchema, () => v.parse(SandboxOpenSandboxSchema, {})),
407
+ cloudflare: v.optional(SandboxCloudflareSchema, () => v.parse(SandboxCloudflareSchema, {})),
408
+ });
305
409
  export const AgentConfigSchema = v.object({
306
410
  name: v.string(),
307
411
  coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
@@ -330,6 +434,13 @@ export const AgentConfigSchema = v.object({
330
434
  // (PATH/HOME/USER/LANG/TERM/TMPDIR) that agent_flue.ts's local() sandbox
331
435
  // would keep anyway.
332
436
  env_allowlist: v.optional(v.nullable(v.array(v.string()))),
437
+ // Per-agent sandbox backend override (SPF #15) — a bare backend name, not
438
+ // a nested block; everything else (image, egress, transport, credentials
439
+ // POLICY) stays repo-scoped on `sandbox:` above.
440
+ // unset -> inherit sandbox.backend
441
+ // "local" -> force local for this agent
442
+ // null -> same as unset (the writes/env_allowlist spelling)
443
+ sandbox: v.optional(v.nullable(SandboxBackendSchema)),
333
444
  });
334
445
  export const ConfigDefaultsSchema = v.object({
335
446
  coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
@@ -504,6 +615,36 @@ export const WatchRefineConfigSchema = v.object({
504
615
  chain: v.optional(v.string(), "refine"),
505
616
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
506
617
  });
618
+ /**
619
+ * Best-of-N per claimed issue — `core/fanout.ts`'s proven machinery pointed at
620
+ * watch's per-issue dispatch. `n: 1` (the DEFAULT) leaves the daemon's
621
+ * behaviour untouched: `core/watch.ts`'s `runIssue` takes its existing
622
+ * single-dispatch path, same adw_id (`issue-<id>`), same one worktree, same
623
+ * everything. (`spf doctor` gains one informational line either way — see
624
+ * that command; nothing about a running daemon changes.)
625
+ *
626
+ * `concurrency` is DELIBERATELY NOT `watch.concurrency`, which counts ISSUES in
627
+ * flight (`core/watch.ts`'s `state.inflight`, a set of issue ids). This one
628
+ * counts ATTEMPTS in flight WITHIN one issue. The two multiply — see
629
+ * `spf doctor`'s `watch.fanout budget` line, which prints the product rather
630
+ * than leaving an operator to discover it on an invoice. Same "each axis of
631
+ * parallelism gets its own named budget" rule `refine.concurrency` already
632
+ * follows (see `WatchRunState.refining`'s doc comment).
633
+ *
634
+ * NOTE that `concurrency` bounds attempts IN FLIGHT, not worktrees ON DISK: a
635
+ * successful attempt's tree is KEPT until every sibling has settled (it is a
636
+ * selection candidate), so peak disk is `n` trees per issue regardless of this
637
+ * number. Doctor prints both.
638
+ *
639
+ * The `n` ceiling is 8, the twin of `MAX_N` in `cli/commands/fanout.ts` — kept
640
+ * as a literal here rather than imported because this module imports nothing
641
+ * but valibot, on purpose (see `SandboxSpec`'s placement note). A daemon typing
642
+ * a bigger number is strictly worse than an interactive command doing so.
643
+ */
644
+ export const WatchFanoutConfigSchema = v.object({
645
+ n: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(8)), 1),
646
+ concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
647
+ });
507
648
  export const WatchConfigSchema = v.object({
508
649
  issue_provider: v.optional(WatchIssueProviderSchema, "github"),
509
650
  code_host: v.optional(WatchCodeHostSchema, "github"),
@@ -532,6 +673,7 @@ export const WatchConfigSchema = v.object({
532
673
  chain_options: v.optional(v.record(v.string(), v.string()), () => ({})),
533
674
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
534
675
  refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
676
+ fanout: v.optional(WatchFanoutConfigSchema, () => v.parse(WatchFanoutConfigSchema, {})),
535
677
  });
536
678
  /**
537
679
  * Optional outbound push for unattended work (`spf watch`, any chain run) —
@@ -666,6 +808,7 @@ export const SFConfigSchema = v.object({
666
808
  notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
667
809
  review: v.optional(ReviewConfigSchema, () => v.parse(ReviewConfigSchema, {})),
668
810
  tiering: v.optional(TieringConfigSchema, () => v.parse(TieringConfigSchema, {})),
811
+ sandbox: v.optional(SandboxConfigSchema, () => v.parse(SandboxConfigSchema, {})),
669
812
  });
670
813
  // ── Tracing ──────────────────────────────────────────────────────────────────
671
814
  /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */