@edgehero/pi-dispatch 4.3.1 → 5.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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The trust-path reset of a recovered workspace (issue #645, durable runs).
3
+ *
4
+ * A recovery restores the crashed attempt's `/workspace` from its checkpoint, and that tree was written by the agent. Most
5
+ * of it is the agent's work and is meant to come back. A few paths are not work: they are what pi LOADS when it starts,
6
+ * as trusted configuration, before the agent says anything. A fresh job gets them from the base repository's
7
+ * default-branch commit, which is merge-gated content ("a serviced repo's .pi/extensions really does load in a job",
8
+ * AGENTS.md). A recovered job would get them from the agent: a `.pi/extensions/evil.js` it planted in attempt 1, or an
9
+ * `AGENTS.md` it rewrote, would load as the repository's own code and instructions in the recovery's fresh pi. So
10
+ * before the container starts, every such path is put back to its content at the base commit, and one absent there is
11
+ * deleted.
12
+ *
13
+ * WHICH PATHS, and they are pi's, not ours (`TRUST_ROOT_NAMES`, held to the pinned pi by
14
+ * worker/test/trust-paths.test.mjs, which reads pi's own resource loader and package manager):
15
+ * - `.pi/` whole (pi's `CONFIG_DIR_NAME`): extensions, skills, prompts, themes, SYSTEM.md, APPEND_SYSTEM.md, settings;
16
+ * - `.agents/` whole: pi loads `.agents/skills` from the working directory and its ancestors up to the repository root;
17
+ * - the root context files pi's `loadContextFileFromDir` tries, in its casings: `AGENTS.override.md`, `AGENTS.md`,
18
+ * `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`.
19
+ * pi reads context files from the working directory and its ANCESTORS only, never from a subdirectory, and the job's
20
+ * working directory is the workspace root, so a nested `AGENTS.md` is ordinary work and is left alone.
21
+ *
22
+ * CASE. The names are matched case-insensitively at the root: on a case-insensitive host filesystem (macOS, a Windows
23
+ * share) `agents.md` IS `AGENTS.md` to pi's `existsSync`, so a planted lowercase file must go too. Removing every case
24
+ * variant and writing back only what the base tree holds is exact on both kinds of filesystem.
25
+ *
26
+ * HOW, and why not the obvious `git checkout <base> -- .pi`: that runs git with the AGENT'S working tree, so its
27
+ * `.gitattributes` choose filters and encodings and its symlinks sit in the paths git writes through. Here the working
28
+ * tree is never consulted. The removal is `fs.rm` (which removes a link, never what it points at), and the content comes
29
+ * by object id from the workspace's `.git` (`ls-tree`, `cat-file`) with `--git-dir` explicit, so git discovers nothing.
30
+ * That `.git` is still the worker's own fresh clone from the checkpoint store: a checkpoint never carries a `.git`
31
+ * segment (checkpoint-paths.mjs), so the restore cannot have written one, and it is lstat'd as a real directory first.
32
+ * Every path written comes from the trusted base tree, regular files are created exclusively (`wx`), and symlinks the
33
+ * base holds are written LAST, so no write of this reset can traverse a link it created.
34
+ */
35
+ import { execFile } from "node:child_process";
36
+ import * as nodeFsp from "node:fs/promises";
37
+ import { dirname, join } from "node:path";
38
+ import { promisify } from "node:util";
39
+ import { GIT_READ_FLAGS } from "./git-hardening.mjs";
40
+
41
+ const exec = promisify(execFile);
42
+
43
+ /** pi's own config directory name (`CONFIG_DIR_NAME`, dist/config.js: the package's `piConfig.configDir`, `.pi` at the pin). */
44
+ export const PI_CONFIG_DIR = ".pi";
45
+ /** The directory pi loads `skills/` from in the working directory and its ancestors (dist/core/package-manager.js). */
46
+ export const AGENTS_DIR = ".agents";
47
+ /** pi's `loadContextFileFromDir` candidates, in pi's order and casings (dist/core/resource-loader.js). */
48
+ export const CONTEXT_FILE_NAMES = Object.freeze(["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
49
+ /** Every root entry the reset owns. */
50
+ export const TRUST_ROOT_NAMES = Object.freeze([PI_CONFIG_DIR, AGENTS_DIR, ...CONTEXT_FILE_NAMES]);
51
+
52
+ const FOLDED = new Set(TRUST_ROOT_NAMES.map((n) => n.toLowerCase()));
53
+ /** The most entries one reset writes back. The base's `.pi/` was already held to the materialiser's caps (256 files). */
54
+ const MAX_ENTRIES = 10_000;
55
+ /** The largest single blob written back: a trust file is configuration, and execFile's buffer must stay bounded. */
56
+ const MAX_BLOB_BYTES = 64 * 1024 * 1024;
57
+ const OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
58
+
59
+ /** Whether a root entry name is one the reset owns, in any casing. */
60
+ export function isTrustRootName(name) {
61
+ return typeof name === "string" && FOLDED.has(name.toLowerCase());
62
+ }
63
+
64
+ /** The default git: one hardened read against an explicit git dir, buffer out. */
65
+ async function defaultGit(gitDir, args, { maxBuffer = MAX_BLOB_BYTES } = {}) {
66
+ const { stdout } = await exec("git", [...GIT_READ_FLAGS, "--literal-pathspecs", `--git-dir=${gitDir}`, ...args], { encoding: "buffer", maxBuffer, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" } });
67
+ return stdout;
68
+ }
69
+
70
+ /** `ls-tree -z` records as `{ mode, type, oid, path }`; a record that does not parse throws (the base is git's own). */
71
+ function parseTree(buf) {
72
+ const out = [];
73
+ for (const rec of buf.toString("utf8").split("\0")) {
74
+ if (rec === "") continue;
75
+ const m = /^(\d{6}) (blob|tree|commit) ([0-9a-f]+)\t(.+)$/s.exec(rec);
76
+ if (!m || !OID_RE.test(m[3])) throw Object.assign(new Error("ls-tree output did not parse"), { code: "TREE_PARSE" });
77
+ out.push({ mode: m[1], type: m[2], oid: m[3], path: m[4] });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** A path from the base tree, refused if it could leave the workspace (git never writes one, but this is the host). */
83
+ function safeRel(path) {
84
+ const segs = path.split("/");
85
+ if (segs.some((s) => s === "" || s === "." || s === ".." || s.includes("\\") || s.toLowerCase() === ".git")) throw Object.assign(new Error("unsafe path in the base tree"), { code: "TREE_PATH" });
86
+ return segs;
87
+ }
88
+
89
+ /**
90
+ * Whether the commit `baseSha` holds anything under `.pi/extensions/` (issue #645), read by object id from the
91
+ * workspace's own `.git` (the worker's fresh clone, or the clone from the checkpoint store; never the working tree).
92
+ * `.pi` and `extensions` are matched case-insensitively, for the reason the reset's names are. A run whose base answers
93
+ * true is not recoverable: a merge-gated extension may import a file outside `.pi/` or a package from the workspace's
94
+ * `node_modules`, which in a recovered run is agent-written, and no reset can enumerate an extension's imports.
95
+ * Throws (with a code) when the tree cannot be read; the caller fails closed.
96
+ */
97
+ export async function baseHasExtensions({ workspace, baseSha, git = defaultGit, fs = nodeFsp } = {}) {
98
+ if (typeof workspace !== "string" || workspace === "" || typeof baseSha !== "string" || !OID_RE.test(baseSha)) {
99
+ throw Object.assign(new Error("baseHasExtensions: bad arguments"), { code: "BAD_ARGS" });
100
+ }
101
+ const gitDir = join(workspace, ".git");
102
+ if (!(await fs.lstat(gitDir)).isDirectory()) throw Object.assign(new Error("the workspace's .git is not a directory"), { code: "GIT_DIR" });
103
+ for (const top of parseTree(await git(gitDir, ["ls-tree", "-z", baseSha]))) {
104
+ if (top.type !== "tree" || top.path.toLowerCase() !== PI_CONFIG_DIR) continue;
105
+ for (const sub of parseTree(await git(gitDir, ["ls-tree", "-z", baseSha, "--", `${top.path}/`]))) {
106
+ const name = sub.path.split("/").at(-1);
107
+ if (name.toLowerCase() === "extensions") return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Put every trust path of `workspace` back to its content at `baseSha`: `{ deleted, written }` (counts only), or a throw
115
+ * with a `code` and no path in its message. The caller turns a throw into a never-started retry: a recovered run whose
116
+ * trust paths could not be reset must not start, since it would start on the agent's configuration.
117
+ */
118
+ export async function resetTrustPaths({ workspace, baseSha, git = defaultGit, fs = nodeFsp } = {}) {
119
+ if (typeof workspace !== "string" || workspace === "" || typeof baseSha !== "string" || !OID_RE.test(baseSha)) {
120
+ throw Object.assign(new Error("resetTrustPaths: bad arguments"), { code: "BAD_ARGS" });
121
+ }
122
+ const gitDir = join(workspace, ".git");
123
+ const st = await fs.lstat(gitDir);
124
+ if (!st.isDirectory()) throw Object.assign(new Error("the workspace's .git is not a directory"), { code: "GIT_DIR" });
125
+
126
+ // 1. Remove every root entry the reset owns, in any casing. `rm` removes a link itself, never its target.
127
+ let deleted = 0;
128
+ for (const name of await fs.readdir(workspace)) {
129
+ if (!isTrustRootName(name)) continue;
130
+ await fs.rm(join(workspace, name), { recursive: true, force: true });
131
+ deleted++;
132
+ }
133
+
134
+ // 2. What the base holds at those names: the root listing, then each owned tree recursively.
135
+ const root = parseTree(await git(gitDir, ["ls-tree", "-z", baseSha])).filter((e) => isTrustRootName(e.path));
136
+ const entries = [];
137
+ for (const e of root) {
138
+ if (e.type === "tree") entries.push(...parseTree(await git(gitDir, ["ls-tree", "-r", "-z", baseSha, "--", e.path])));
139
+ else entries.push(e);
140
+ if (entries.length > MAX_ENTRIES) throw Object.assign(new Error("too many trust entries in the base"), { code: "TOO_MANY" });
141
+ }
142
+
143
+ // 3. Write back: directories and regular files first, symlinks last.
144
+ let written = 0;
145
+ const links = [];
146
+ for (const e of entries) {
147
+ const segs = safeRel(e.path);
148
+ const target = join(workspace, ...segs);
149
+ if (e.type === "commit") {
150
+ // A submodule: a checkout leaves an empty directory, and so does this.
151
+ await fs.mkdir(target, { recursive: true });
152
+ continue;
153
+ }
154
+ if (e.mode === "120000") {
155
+ links.push({ target, oid: e.oid });
156
+ continue;
157
+ }
158
+ if (e.mode !== "100644" && e.mode !== "100755") continue;
159
+ await fs.mkdir(dirname(target), { recursive: true });
160
+ const bytes = await git(gitDir, ["cat-file", "blob", e.oid]);
161
+ await fs.writeFile(target, bytes, { flag: "wx", mode: e.mode === "100755" ? 0o755 : 0o644 });
162
+ written++;
163
+ }
164
+ for (const l of links) {
165
+ await fs.mkdir(dirname(l.target), { recursive: true });
166
+ const to = (await git(gitDir, ["cat-file", "blob", l.oid], { maxBuffer: 4096 })).toString("utf8");
167
+ await fs.symlink(to, l.target);
168
+ written++;
169
+ }
170
+ return { deleted, written };
171
+ }