@foldrun/core 1.4.2 → 2.1.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 (57) hide show
  1. package/dist/index.d.ts +0 -3
  2. package/dist/index.js +0 -3
  3. package/dist/src/catalog.d.ts +7 -0
  4. package/dist/src/catalog.js +16 -1
  5. package/dist/src/deploy.d.ts +6 -27
  6. package/dist/src/deploy.js +14 -56
  7. package/dist/src/history.d.ts +30 -10
  8. package/dist/src/history.js +232 -99
  9. package/dist/src/library.d.ts +0 -13
  10. package/dist/src/library.js +0 -50
  11. package/dist/src/model-credential.js +1 -1
  12. package/dist/src/model-env.js +1 -1
  13. package/dist/src/platform.d.ts +1 -3
  14. package/dist/src/platform.js +0 -1
  15. package/dist/src/run-container.d.ts +31 -5
  16. package/dist/src/run-container.js +71 -3
  17. package/dist/src/runner.d.ts +2 -0
  18. package/dist/src/runner.js +20 -12
  19. package/dist/src/secrets.js +1 -12
  20. package/dist/src/starter.js +2 -2
  21. package/dist/src/step-exec.d.ts +23 -5
  22. package/dist/src/step-exec.js +30 -23
  23. package/dist/src/store.d.ts +61 -22
  24. package/dist/src/store.js +105 -25
  25. package/dist/src/tool-loop.d.ts +4 -1
  26. package/dist/src/tool-loop.js +2 -0
  27. package/dist/src/translator.d.ts +12 -0
  28. package/dist/src/translator.js +36 -13
  29. package/index.ts +0 -3
  30. package/package.json +1 -3
  31. package/src/catalog.ts +18 -2
  32. package/src/deploy.ts +13 -74
  33. package/src/history.ts +230 -108
  34. package/src/library.ts +0 -44
  35. package/src/model-credential.ts +1 -1
  36. package/src/model-env.ts +1 -1
  37. package/src/platform.ts +1 -4
  38. package/src/run-container.ts +78 -5
  39. package/src/runner.ts +22 -12
  40. package/src/secrets.ts +1 -11
  41. package/src/starter.ts +2 -2
  42. package/src/step-exec.ts +44 -27
  43. package/src/store.ts +145 -37
  44. package/src/tool-loop.ts +6 -1
  45. package/src/translator.ts +39 -15
  46. package/dist/src/git-hook.d.ts +0 -1
  47. package/dist/src/git-hook.js +0 -70
  48. package/dist/src/git.d.ts +0 -37
  49. package/dist/src/git.js +0 -101
  50. package/dist/src/gitrepo.d.ts +0 -113
  51. package/dist/src/gitrepo.js +0 -404
  52. package/dist/src/tar.d.ts +0 -30
  53. package/dist/src/tar.js +0 -112
  54. package/src/git-hook.ts +0 -73
  55. package/src/git.ts +0 -117
  56. package/src/gitrepo.ts +0 -439
  57. package/src/tar.ts +0 -125
package/dist/src/store.js CHANGED
@@ -385,7 +385,6 @@ meta = {}) {
385
385
  // about it. Before this, every deploy silently erased everything the agents
386
386
  // had learned.
387
387
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
388
- const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
389
388
  // What is there now, for the revision: every editable file's content
390
389
  // before this save replaces the tree.
391
390
  const before = new Map();
@@ -399,6 +398,7 @@ meta = {}) {
399
398
  }
400
399
  }
401
400
  }
401
+ const isAgentOwned = (rel) => keptOnDeploy(rel, shipped) || (meta.keepUntracked === true && !before.has(rel) && !shipped.has(rel));
402
402
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
403
403
  const preserved = [];
404
404
  if (snapshot) {
@@ -406,11 +406,15 @@ meta = {}) {
406
406
  const rel = String(entry).split(path.sep).join("/");
407
407
  const from = path.join(dir, rel);
408
408
  try {
409
- if (!fs.statSync(from).isFile() || !isAgentOwned(rel))
409
+ // lstat, never stat: a link is never followed. An agent folder's link
410
+ // to a file outside the workspace was copied in as that file's
411
+ // contents — on a restore with keepUntracked, any such link
412
+ // (Codex, 18 Oct 2026).
413
+ if (!fs.lstatSync(from).isFile() || !isAgentOwned(rel))
410
414
  continue;
411
415
  }
412
416
  catch {
413
- continue; // a broken link or a file that vanished mid-deploy
417
+ continue; // a file that vanished mid-deploy
414
418
  }
415
419
  const to = path.join(snapshot, rel);
416
420
  fs.mkdirSync(path.dirname(to), { recursive: true });
@@ -1681,7 +1685,7 @@ export function mayApprove(flow, who) {
1681
1685
  }
1682
1686
  /** A live run of this flow — the fact overlap: decisions are made on. */
1683
1687
  export function flowHasLiveRun(tenant, workspace, flowName) {
1684
- return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
1688
+ return listRunSummaries(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
1685
1689
  }
1686
1690
  // Split a flow file into its frontmatter, any prose before the first step,
1687
1691
  // and one block per step. Blocks come back in the same order as parseFlow's
@@ -2012,13 +2016,6 @@ export function listWorkspaces(tenant) {
2012
2016
  }
2013
2017
  }
2014
2018
  const runsDir = path.join(pDir, "runs");
2015
- let preview;
2016
- try {
2017
- preview = JSON.parse(fs.readFileSync(path.join(dir, `.${name}.preview.json`), "utf8"));
2018
- }
2019
- catch {
2020
- // not a preview
2021
- }
2022
2019
  return {
2023
2020
  name,
2024
2021
  description,
@@ -2026,7 +2023,6 @@ export function listWorkspaces(tenant) {
2026
2023
  flows: listFlows(tenant, name).length,
2027
2024
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
2028
2025
  runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
2029
- ...(preview ? { preview } : {}),
2030
2026
  };
2031
2027
  })
2032
2028
  .sort((a, b) => b.deployedAt.localeCompare(a.deployedAt));
@@ -2581,10 +2577,62 @@ export function sumAttempts(tries) {
2581
2577
  const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
2582
2578
  return {
2583
2579
  costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
2584
- tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
2580
+ tokens: toks.length ? sumTokens(toks) : null,
2585
2581
  computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
2586
2582
  };
2587
2583
  }
2584
+ /** The record's shape from what a step reports (ExecOutcome.usage). */
2585
+ export function tokensOf(usage) {
2586
+ if (!usage)
2587
+ return null;
2588
+ return {
2589
+ input: usage.inputTokens,
2590
+ output: usage.outputTokens,
2591
+ cacheRead: usage.cacheReadTokens ?? 0,
2592
+ cacheWrite: usage.cacheWriteTokens ?? 0,
2593
+ };
2594
+ }
2595
+ /** Field-by-field sum; the cache fields are present in the result whenever
2596
+ * any input had them, so a run of old and new steps still reads whole. */
2597
+ export function sumTokens(list) {
2598
+ const out = { input: 0, output: 0 };
2599
+ for (const t of list) {
2600
+ out.input += t.input;
2601
+ out.output += t.output;
2602
+ if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
2603
+ out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
2604
+ out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
2605
+ }
2606
+ }
2607
+ return out;
2608
+ }
2609
+ /** The share of all input that the prompt cache served, 0–1; null when
2610
+ * there was no input, or the record predates the split and cannot say. */
2611
+ export function cacheHitRate(t) {
2612
+ if (!t || t.cacheRead === undefined)
2613
+ return null;
2614
+ return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
2615
+ }
2616
+ /** The cache share over several counts, taking only those that report the
2617
+ * split: a step recorded before it holds its cache traffic inside `input`,
2618
+ * and counting that as uncached would understate the rate. The rule the
2619
+ * Usage page and the CLI's totals follow too. */
2620
+ export function measuredHitRate(list) {
2621
+ let read = 0;
2622
+ let all = 0;
2623
+ for (const t of list) {
2624
+ if (!t || t.cacheRead === undefined)
2625
+ continue;
2626
+ read += t.cacheRead;
2627
+ all += t.input;
2628
+ }
2629
+ return all > 0 ? read / all : null;
2630
+ }
2631
+ /** A run's token counts, summed over its steps; null when no step reported any. */
2632
+ export function runTokens(run) {
2633
+ const toks = run.steps.map((s) => s.tokens).filter((t) => !!t);
2634
+ return toks.length ? sumTokens(toks) : null;
2635
+ }
2588
2636
  export function runFilePath(tenant, workspace, runId) {
2589
2637
  assertSafeName(runId, "run id");
2590
2638
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -2697,6 +2745,24 @@ export function writeRun(tenant, workspace, run) {
2697
2745
  fs.renameSync(tmp, p);
2698
2746
  noteRunWritten(path.dirname(p), run, p);
2699
2747
  }
2748
+ /**
2749
+ * The workspace's live runs — queued, running, or parked at a gate — read
2750
+ * whole, newest first. Off the run index: only the live records are opened,
2751
+ * where listRuns parses every record the workspace ever kept. What the
2752
+ * periodic sweeps want (reconcile, the watchdog), and on a box that keeps
2753
+ * ninety days of runs that is a handful of files instead of thousands.
2754
+ */
2755
+ export function liveRuns(tenant, workspace) {
2756
+ const out = [];
2757
+ for (const s of listRunSummaries(tenant, workspace)) {
2758
+ if (!LIVE_STATUSES.has(s.status))
2759
+ continue;
2760
+ const run = readRun(tenant, workspace, s.id);
2761
+ if (run)
2762
+ out.push(run);
2763
+ }
2764
+ return out;
2765
+ }
2700
2766
  /**
2701
2767
  * Every run in the account, newest first, off the per-workspace index.
2702
2768
  *
@@ -2721,6 +2787,13 @@ export function listAllRuns(tenant) {
2721
2787
  }))
2722
2788
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2723
2789
  }
2790
+ /**
2791
+ * Which version of a file this is. mtime alone missed a rewrite inside one
2792
+ * mtime tick (coarse on some NFS servers), leaving a stale row that the
2793
+ * sweeps act on; every write here is a rename to a new file, so the inode
2794
+ * moves on each write whatever the clock says, and the size usually does.
2795
+ */
2796
+ const fileStamp = (st) => `${st.mtimeMs}:${st.ino}:${st.size}`;
2724
2797
  const RUN_INDEX = ".index.json";
2725
2798
  const LIVE_STATUSES = new Set(["queued", "running", "awaiting-approval"]);
2726
2799
  /** One run's row. */
@@ -2780,22 +2853,22 @@ export function runFromSummary(s) {
2780
2853
  const runIndexCache = new Map();
2781
2854
  function readRunIndex(dir) {
2782
2855
  const file = path.join(dir, RUN_INDEX);
2783
- let mtimeMs;
2856
+ let stamp;
2784
2857
  try {
2785
- mtimeMs = fs.statSync(file).mtimeMs;
2858
+ stamp = fileStamp(fs.statSync(file));
2786
2859
  }
2787
2860
  catch {
2788
2861
  runIndexCache.delete(dir);
2789
2862
  return null;
2790
2863
  }
2791
2864
  const cached = runIndexCache.get(dir);
2792
- if (cached && cached.mtimeMs === mtimeMs)
2865
+ if (cached && cached.stamp === stamp)
2793
2866
  return cached.index;
2794
2867
  try {
2795
2868
  const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2796
2869
  if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs)
2797
2870
  return null;
2798
- runIndexCache.set(dir, { index: parsed, mtimeMs });
2871
+ runIndexCache.set(dir, { index: parsed, stamp });
2799
2872
  return parsed;
2800
2873
  }
2801
2874
  catch {
@@ -2808,13 +2881,18 @@ function writeRunIndex(dir, index) {
2808
2881
  fs.writeFileSync(tmp, JSON.stringify(index));
2809
2882
  fs.renameSync(tmp, file);
2810
2883
  try {
2811
- runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
2884
+ runIndexCache.set(dir, { index, stamp: fileStamp(fs.statSync(file)) });
2812
2885
  }
2813
2886
  catch {
2814
2887
  runIndexCache.delete(dir);
2815
2888
  }
2816
2889
  }
2817
- const sameRow = (a, b) => !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
2890
+ const sameRow = (a, b) => {
2891
+ if (!a)
2892
+ return false;
2893
+ const { mtimeMs: _m, stamp: _s, ...row } = a;
2894
+ return JSON.stringify(row) === JSON.stringify(b);
2895
+ };
2818
2896
  /** writeRun just saved `run` at `file`: bring its row up to date. The file
2819
2897
  * is rewritten only when the row's content moved — a live step appends an
2820
2898
  * event several times a second, and the row does not change for that;
@@ -2828,7 +2906,8 @@ function noteRunWritten(dir, run, file) {
2828
2906
  const row = summarizeRun(run);
2829
2907
  if (sameRow(index.runs[run.id], row))
2830
2908
  return;
2831
- index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
2909
+ const st = fs.statSync(file);
2910
+ index.runs[run.id] = { ...row, mtimeMs: st.mtimeMs, stamp: fileStamp(st) };
2832
2911
  writeRunIndex(dir, index);
2833
2912
  }
2834
2913
  catch {
@@ -2866,19 +2945,20 @@ export function listRunSummaries(tenant, workspace) {
2866
2945
  continue;
2867
2946
  const id = f.slice(0, -".json".length);
2868
2947
  present.add(id);
2869
- let mtimeMs;
2948
+ let st;
2870
2949
  try {
2871
- mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
2950
+ st = fs.statSync(path.join(dir, f));
2872
2951
  }
2873
2952
  catch {
2874
2953
  continue; // pruned between the listing and the stat
2875
2954
  }
2955
+ const stamp = fileStamp(st);
2876
2956
  const row = index.runs[id];
2877
- if (row && row.mtimeMs === mtimeMs)
2957
+ if (row && row.stamp === stamp)
2878
2958
  continue;
2879
2959
  try {
2880
2960
  const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
2881
- index.runs[id] = { ...summarizeRun(run), mtimeMs };
2961
+ index.runs[id] = { ...summarizeRun(run), mtimeMs: st.mtimeMs, stamp };
2882
2962
  changed = true;
2883
2963
  }
2884
2964
  catch {
@@ -2900,7 +2980,7 @@ export function listRunSummaries(tenant, workspace) {
2900
2980
  }
2901
2981
  }
2902
2982
  return Object.values(index.runs)
2903
- .map(({ mtimeMs: _m, ...row }) => row)
2983
+ .map(({ mtimeMs: _m, stamp: _s, ...row }) => row)
2904
2984
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2905
2985
  }
2906
2986
  /** Every workspace's runs as summaries, tagged with the workspace. */
@@ -13,9 +13,12 @@ export interface LoopTool {
13
13
  run: (args: Record<string, string | undefined>) => Promise<string> | string;
14
14
  }
15
15
  export interface LoopUsage {
16
- /** Everything sent, cache reads and writes included. */
16
+ /** Everything sent, cache reads and writes included; the two cache
17
+ * fields say how much of it the prompt cache served or stored. */
17
18
  inputTokens: number;
18
19
  outputTokens: number;
20
+ cacheReadTokens: number;
21
+ cacheWriteTokens: number;
19
22
  costUsd: number;
20
23
  }
21
24
  export interface LoopResult {
@@ -170,6 +170,8 @@ export async function runToolLoop(o) {
170
170
  usage = {
171
171
  inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
172
172
  outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
173
+ cacheReadTokens: models.reduce((n, m) => n + (m.cacheReadInputTokens ?? 0), 0),
174
+ cacheWriteTokens: models.reduce((n, m) => n + (m.cacheCreationInputTokens ?? 0), 0),
173
175
  costUsd,
174
176
  };
175
177
  if (message.subtype === "success") {
@@ -1,4 +1,16 @@
1
1
  type Json = Record<string, unknown>;
2
+ /**
3
+ * An OpenAI-shaped usage object as Anthropic's: fresh input, cached input,
4
+ * output. Both OpenAI shapes count the cached part INSIDE the prompt total
5
+ * (Chat's prompt_tokens_details.cached_tokens, Responses'
6
+ * input_tokens_details.cached_tokens; DeepSeek says prompt_cache_hit_tokens),
7
+ * so it is taken out of input here or the step would count it twice.
8
+ */
9
+ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire): {
10
+ input: number;
11
+ cached: number;
12
+ output: number;
13
+ };
2
14
  export type TranslatorWire = "chat" | "responses";
3
15
  export interface TranslatorSpec {
4
16
  /** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
@@ -22,7 +22,9 @@
22
22
  // and their results, streaming — and drops what a Chat-Completions endpoint
23
23
  // has no word for (cache_control, top_k, server-side tools, thinking on
24
24
  // endpoints that lack reasoning_effort). Anything dropped is said once in
25
- // the run trace. The translation is pure functions over JSON so the tests
25
+ // the run trace. Those endpoints cache prompt prefixes on their own side;
26
+ // the cached share they report comes back as cache_read_input_tokens, so a
27
+ // step's record says how much of its context was re-read. The translation is pure functions over JSON so the tests
26
28
  // can drive every mapping without a network; the server around them is a
27
29
  // few dozen lines.
28
30
  import http from "node:http";
@@ -31,6 +33,33 @@ import { PROTECTED_PARAMS } from "./providers.js";
31
33
  import { viaEgress } from "./egress.js";
32
34
  import { trimSlashes } from "./paths.js";
33
35
  import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
36
+ /**
37
+ * An OpenAI-shaped usage object as Anthropic's: fresh input, cached input,
38
+ * output. Both OpenAI shapes count the cached part INSIDE the prompt total
39
+ * (Chat's prompt_tokens_details.cached_tokens, Responses'
40
+ * input_tokens_details.cached_tokens; DeepSeek says prompt_cache_hit_tokens),
41
+ * so it is taken out of input here or the step would count it twice.
42
+ */
43
+ export function anthropicUsage(u, wire) {
44
+ if (!u)
45
+ return { input: 0, cached: 0, output: 0 };
46
+ const total = Number((wire === "chat" ? u.prompt_tokens : u.input_tokens) ?? 0);
47
+ const details = (wire === "chat" ? u.prompt_tokens_details : u.input_tokens_details);
48
+ const raw = Number(details?.cached_tokens ?? u.prompt_cache_hit_tokens ?? 0);
49
+ const cached = Number.isFinite(raw) ? Math.max(0, Math.min(raw, total)) : 0;
50
+ return {
51
+ input: total - cached,
52
+ cached,
53
+ output: Number((wire === "chat" ? u.completion_tokens : u.output_tokens) ?? 0),
54
+ };
55
+ }
56
+ function usageBlock(u) {
57
+ return {
58
+ input_tokens: u?.input ?? 0,
59
+ output_tokens: u?.output ?? 0,
60
+ ...(u && u.cached > 0 ? { cache_read_input_tokens: u.cached } : {}),
61
+ };
62
+ }
34
63
  function dropped() {
35
64
  const seen = new Set();
36
65
  return {
@@ -466,10 +495,7 @@ export function fromChatCompletion(res, requestedModel, stopSequences) {
466
495
  content,
467
496
  stop_reason: stop.reason,
468
497
  stop_sequence: stop.sequence,
469
- usage: {
470
- input_tokens: Number(usage.prompt_tokens ?? 0),
471
- output_tokens: Number(usage.completion_tokens ?? 0),
472
- },
498
+ usage: usageBlock(anthropicUsage(usage, "chat")),
473
499
  };
474
500
  }
475
501
  /**
@@ -532,7 +558,7 @@ export class StreamTranslator {
532
558
  const out = this.start(chunk);
533
559
  const u = chunk.usage;
534
560
  if (u && (u.prompt_tokens !== undefined || u.completion_tokens !== undefined)) {
535
- this.usage = { input: Number(u.prompt_tokens ?? 0), output: Number(u.completion_tokens ?? 0) };
561
+ this.usage = anthropicUsage(u, "chat");
536
562
  }
537
563
  const choice = (chunk.choices ?? [])[0];
538
564
  if (!choice)
@@ -609,7 +635,7 @@ export class StreamTranslator {
609
635
  out.push(this.event("message_delta", {
610
636
  type: "message_delta",
611
637
  delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
612
- usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
638
+ usage: usageBlock(this.usage),
613
639
  }));
614
640
  out.push(this.event("message_stop", { type: "message_stop" }));
615
641
  return out;
@@ -648,10 +674,7 @@ export function fromResponses(res, requestedModel) {
648
674
  content,
649
675
  stop_reason: stop,
650
676
  stop_sequence: null,
651
- usage: {
652
- input_tokens: Number(usage.input_tokens ?? 0),
653
- output_tokens: Number(usage.output_tokens ?? 0),
654
- },
677
+ usage: usageBlock(anthropicUsage(usage, "responses")),
655
678
  };
656
679
  }
657
680
  /**
@@ -768,7 +791,7 @@ export class ResponsesStreamTranslator {
768
791
  const r = ev.response ?? {};
769
792
  const u = r.usage;
770
793
  if (u)
771
- this.usage = { input: Number(u.input_tokens ?? 0), output: Number(u.output_tokens ?? 0) };
794
+ this.usage = anthropicUsage(u, "responses");
772
795
  const reason = r.incomplete_details?.reason;
773
796
  if (typeof reason === "string")
774
797
  this.incomplete = reason;
@@ -790,7 +813,7 @@ export class ResponsesStreamTranslator {
790
813
  out.push(this.event("message_delta", {
791
814
  type: "message_delta",
792
815
  delta: { stop_reason: reason, stop_sequence: null },
793
- usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
816
+ usage: usageBlock(this.usage),
794
817
  }));
795
818
  out.push(this.event("message_stop", { type: "message_stop" }));
796
819
  return out;
package/index.ts CHANGED
@@ -36,7 +36,6 @@ export * from "./src/tool-names.ts";
36
36
  export * from "./src/history.ts";
37
37
  export * from "./src/zip.ts";
38
38
  export * from "./src/package.ts";
39
- export * from "./src/gitrepo.ts";
40
39
  export * from "./src/storage.ts";
41
40
  export * from "./src/secrets.ts";
42
41
  export * from "./src/oauth-presets.ts";
@@ -47,5 +46,3 @@ export * from "./src/approvals.ts";
47
46
  export * from "./src/deploy.ts";
48
47
  export * from "./src/agent-import.ts";
49
48
  export * from "./src/account.ts";
50
- export * from "./src/tar.ts";
51
- export * from "./src/git.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.4.2",
3
+ "version": "2.1.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -9,7 +9,6 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
11
  },
12
- "./git-hook": "./dist/src/git-hook.js",
13
12
  "./*": {
14
13
  "types": "./dist/src/*.d.ts",
15
14
  "default": "./dist/src/*.js"
@@ -18,7 +17,6 @@
18
17
  "foldrun": {
19
18
  "publicApi": [
20
19
  ".",
21
- "./git-hook",
22
20
  "./agents-md",
23
21
  "./browser-pod",
24
22
  "./flow-patterns",
package/src/catalog.ts CHANGED
@@ -245,13 +245,29 @@ export function clampEffort(
245
245
  export function catalogCost(
246
246
  catalog: Catalog | null,
247
247
  modelId: string,
248
- usage: { inputTokens: number; outputTokens: number },
248
+ usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
249
249
  ): number | null {
250
250
  const m = findModel(catalog, modelId);
251
251
  if (!m || m.promptPrice == null || m.completionPrice == null) return null;
252
- return usage.inputTokens * m.promptPrice + usage.outputTokens * m.completionPrice;
252
+ // A catalogue quotes one input price; cache traffic is priced off it with
253
+ // the multipliers every gateway that bills for caching has copied from
254
+ // Anthropic. Before the counts were split (18 Oct 2026) every cached
255
+ // token was billed here at the full rate — a long cached step cost ten
256
+ // times its price on the ledger.
257
+ // inputTokens is all input; the cache fields are parts of it.
258
+ const read = usage.cacheReadTokens ?? 0;
259
+ const written = usage.cacheWriteTokens ?? 0;
260
+ const fresh = Math.max(0, usage.inputTokens - read - written);
261
+ const input = fresh + written * CACHE_WRITE_MULTIPLIER + read * CACHE_READ_MULTIPLIER;
262
+ return input * m.promptPrice + usage.outputTokens * m.completionPrice;
253
263
  }
254
264
 
265
+ /** Anthropic's cache multipliers over the input price: a write costs a
266
+ * quarter more than fresh input, a read a tenth of it. One source for the
267
+ * mid-step ceiling (priceTurn) and the record's repricing (catalogCost). */
268
+ export const CACHE_WRITE_MULTIPLIER = 1.25;
269
+ export const CACHE_READ_MULTIPLIER = 0.1;
270
+
255
271
  /** Catalogue entries as completion items: price per MTok, context, and the
256
272
  * one capability that decides agent-worthiness. Tool-capable models first —
257
273
  * on an agent platform they are almost always what the author wants. */
package/src/deploy.ts CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  saveWorkspace,
26
26
  keptOnDeploy,
27
27
  workspaceDir,
28
- listRuns,
28
+ listRunSummaries,
29
+ listWorkspaceFiles,
29
30
  parseFlow,
30
31
  parseToolDef,
31
32
  WORKSPACE_DIRS,
@@ -44,7 +45,6 @@ import { networkProblems } from "./network.ts";
44
45
  import { actionProblems } from "./web-actions.ts";
45
46
  import { languageProblem } from "./language.ts";
46
47
  import { localeProblems } from "./locale.ts";
47
- import { repoDir } from "./gitrepo.ts";
48
48
  import { entriesNoFollow } from "./paths.ts";
49
49
  import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
50
50
 
@@ -455,13 +455,13 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
455
455
  */
456
456
  export function runsInFlight(tenant: string, workspace: string): string[] {
457
457
  if (!fs.existsSync(workspaceDir(tenant, workspace))) return [];
458
- return listRuns(tenant, workspace)
458
+ return listRunSummaries(tenant, workspace)
459
459
  .filter((r) => r.status === "queued" || r.status === "running" || r.status === "awaiting-approval")
460
460
  .map((r) => r.id);
461
461
  }
462
462
 
463
463
  /** What a deploy would do, without doing it. */
464
- export function planDeploy(tenant: string, workspace: string, files: DeployFile[]): DeployPlan {
464
+ export function planDeploy(tenant: string, workspace: string, files: DeployFile[], opts: { keepUntracked?: boolean } = {}): DeployPlan {
465
465
  const dir = workspaceDir(tenant, workspace);
466
466
  const shipped = new Map(files.map((f) => [path.normalize(f.path), f.content]));
467
467
 
@@ -499,7 +499,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
499
499
  // memory, the platform's own files. Reporting these as removals would be
500
500
  // a lie, and the alarming kind; one predicate serves both.
501
501
  const shippedPaths = new Set(shipped.keys());
502
- const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths));
502
+ // keepUntracked (a restore): what is not editable source stays, as
503
+ // saveWorkspace keeps it — see its meta.keepUntracked.
504
+ const tracked = opts.keepUntracked ? new Set(listWorkspaceFiles(tenant, workspace)) : null;
505
+ const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths) && (!tracked || tracked.has(rel)));
503
506
 
504
507
  return {
505
508
  files,
@@ -529,6 +532,9 @@ export interface DeployOptions {
529
532
  * no window between the check and the swap. Absent: no check, as before.
530
533
  */
531
534
  expectRemoved?: string[];
535
+ /** Keep what is not editable source (planDeploy, saveWorkspace): a
536
+ * restore takes history's tree, and history tracks source only. */
537
+ keepUntracked?: boolean;
532
538
  }
533
539
 
534
540
  /**
@@ -539,73 +545,6 @@ export interface DeployOptions {
539
545
  * webhook or a CLI that needs to report *why*, and every reason is already in
540
546
  * the plan.
541
547
  */
542
- /** What a push recorded about itself, where the Repository page reads it. */
543
- export interface PushNote {
544
- commit: string;
545
- at: string;
546
- applied: boolean;
547
- issues?: unknown[];
548
- blockedBy?: string[];
549
- /** Set when a blocked push was applied later, by the retry. */
550
- appliedAt?: string;
551
- }
552
-
553
- export const pushNotePath = (tenant: string, workspace: string) =>
554
- path.join(repoDir(tenant, workspace), "foldrun-last-push.json");
555
-
556
- export function readPushNote(tenant: string, workspace: string): PushNote | null {
557
- try {
558
- return JSON.parse(fs.readFileSync(pushNotePath(tenant, workspace), "utf8")) as PushNote;
559
- } catch {
560
- return null;
561
- }
562
- }
563
-
564
- export function writePushNote(tenant: string, workspace: string, note: PushNote) {
565
- try {
566
- fs.writeFileSync(pushNotePath(tenant, workspace), JSON.stringify(note));
567
- } catch {
568
- // The note is a record, not the deploy. Losing it must not fail a push.
569
- }
570
- // A push waiting on a run is the scheduler's to apply; tell it there is one.
571
- notifyWorkspaceChanged(tenant, workspace, "push");
572
- }
573
-
574
- /**
575
- * A push whose deploy was refused only because a run was in flight is not a
576
- * failure — it is a deploy that has not happened yet. Git already said the
577
- * push succeeded (the deploy happens after the client is gone), so nothing
578
- * would ever tell anyone the workspace on disk is behind `main`. This applies
579
- * it once the runs are done.
580
- *
581
- * Only `blockedBy`: a push refused for `issues` is a broken workspace and
582
- * wants a person, not a retry.
583
- */
584
- export function applyPendingPush(
585
- tenant: string,
586
- workspace: string,
587
- filesAt: (tenant: string, scope: string, ref: string) => DeployFile[],
588
- ): { commit: string } | null {
589
- const note = readPushNote(tenant, workspace);
590
- if (!note || note.applied) return null;
591
- if (!note.blockedBy?.length || (note.issues?.length ?? 0) > 0) return null;
592
- if (runsInFlight(tenant, workspace).length > 0) return null;
593
- // Only the commit the note names, and only while it is still what main
594
- // points at — a newer push supersedes it and wrote its own note.
595
- const result = deployWorkspace(tenant, workspace, filesAt(tenant, workspace, note.commit), {
596
- commit: note.commit,
597
- force: false,
598
- });
599
- writePushNote(tenant, workspace, {
600
- ...note,
601
- applied: result.applied,
602
- issues: result.issues ?? [],
603
- blockedBy: result.blockedBy ?? [],
604
- ...(result.applied ? { appliedAt: new Date().toISOString() } : {}),
605
- });
606
- return result.applied ? { commit: note.commit } : null;
607
- }
608
-
609
548
  export function deployWorkspace(
610
549
  tenant: string,
611
550
  workspace: string,
@@ -615,7 +554,7 @@ export function deployWorkspace(
615
554
  assertSafeName(tenant, "tenant");
616
555
  assertSafeName(workspace, "workspace");
617
556
 
618
- const plan = planDeploy(tenant, workspace, files);
557
+ const plan = planDeploy(tenant, workspace, files, { keepUntracked: opts.keepUntracked });
619
558
  const blocked = plan.blockedBy.length > 0 && !opts.force;
620
559
  if (plan.issues.length > 0 || blocked) {
621
560
  return { ...plan, applied: false, commit: null, preserved: 0 };
@@ -628,7 +567,7 @@ export function deployWorkspace(
628
567
  }
629
568
  }
630
569
 
631
- const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
570
+ const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message, keepUntracked: opts.keepUntracked });
632
571
  const commit = opts.commit ?? null;
633
572
  if (commit) writeDeployedCommit(tenant, workspace, commit);
634
573
  return { ...plan, applied: true, commit, preserved }; // saveWorkspace told the platform