oakode 0.0.0-stage → 0.5.1

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 (53) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +9 -0
  3. package/README.md +245 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +584 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +103 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +588 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +87 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +335 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
@@ -0,0 +1,452 @@
1
+ /** Append-only JSONL sessions. Nothing is ever rewritten: compaction is just another entry. */
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, truncateSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ import { home } from "./config.js";
7
+ import { maskTail } from "./mask.js";
8
+ export const INTERRUPTED = "Interrupted: this tool call did not complete (the operation was cancelled or the session ended).";
9
+ export const estimateTokens = (m) => Math.ceil((("content" in m && m.content ? m.content.length : 0) + ("tool_calls" in m && m.tool_calls ? JSON.stringify(m.tool_calls).length : 0)) / 4) + 4 + (m.role === "user" && m.images ? m.images.length * 1500 : 0);
10
+ export class Session {
11
+ id;
12
+ file;
13
+ cwd;
14
+ model;
15
+ raw = [];
16
+ summary;
17
+ keepFrom = 0;
18
+ system;
19
+ changes = [];
20
+ /** Delegations recorded in this session (subagents), oldest first. */
21
+ delegations = [];
22
+ /** The last recorded run end in the session file (undefined for sessions written before 0.2.2). */
23
+ lastTurn;
24
+ files = { read: [], modified: [] };
25
+ /** Verbatim user request of a turn that a compaction cut through. */
26
+ request;
27
+ /** Set while the session file cannot be written; history is then only in memory (and `backlog`). */
28
+ persistError;
29
+ onPersist;
30
+ backlog = [];
31
+ needsNewline = false;
32
+ lockPath;
33
+ /** Prompt size reported by the last request, and how many raw messages it covered. */
34
+ lastPrompt;
35
+ constructor(file, id, cwd, model) {
36
+ this.file = file;
37
+ this.id = id;
38
+ this.cwd = cwd;
39
+ this.model = model;
40
+ }
41
+ /** One directory per project, keyed by a hash of the real path so distinct paths can never share one. */
42
+ static dir(cwd) {
43
+ const name = (cwd.split(/[\\/]/).filter(Boolean).pop() ?? "root").replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 40);
44
+ return join(home(), "sessions", `${name}-${createHash("sha256").update(cwd).digest("hex").slice(0, 16)}`);
45
+ }
46
+ static create(cwd, model) {
47
+ const id = randomUUID().slice(0, 8);
48
+ const dir = Session.dir(cwd);
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ const s = new Session(join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`), id, cwd, model);
51
+ s.acquire();
52
+ // The file appears with the first message: a session that never had one leaves nothing behind, so it neither
53
+ // clutters /sessions nor gets picked by `oakode -c`.
54
+ s.deferred = true;
55
+ s.write({ type: "session", id, cwd, created: new Date().toISOString(), version: 1 });
56
+ s.write({ type: "model", ts: new Date().toISOString(), model });
57
+ return s;
58
+ }
59
+ /**
60
+ * A subagent's own transcript, kept beside its parent's: <parent's dir>/<parent id>/agents/<name>.jsonl.
61
+ * Session.list reads only the top level, so it never shows up as a session of its own. `cwd` is where its tools act
62
+ * (the parent's workspace, or later an isolated worktree).
63
+ */
64
+ static createChild(parent, name, model, cwd = parent.cwd) {
65
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
66
+ throw new Error(`Invalid subagent session name: ${name}`);
67
+ const dir = join(dirname(parent.file), parent.id, "agents");
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ const file = join(dir, `${name}.jsonl`);
70
+ if (existsSync(file))
71
+ throw new Error(`A subagent session named ${name} already exists for session ${parent.id}.`);
72
+ const s = new Session(file, `${parent.id}.${name}`, cwd, model);
73
+ s.acquire();
74
+ s.write({ type: "session", id: s.id, cwd, created: new Date().toISOString(), version: 1, parent: parent.id });
75
+ s.write({ type: "model", ts: new Date().toISOString(), model });
76
+ return s;
77
+ }
78
+ /** Opens a session file. Refuses sessions recorded for another directory: tools would act on the wrong project. */
79
+ static open(file, fallbackModel, expectedCwd) {
80
+ const text = readFileSync(file, "utf8");
81
+ const lines = text.split("\n").filter(Boolean);
82
+ let header;
83
+ const s = new Session(file, "", "", fallbackModel);
84
+ for (const line of lines) {
85
+ let e;
86
+ try {
87
+ e = JSON.parse(line);
88
+ }
89
+ catch {
90
+ continue; // a torn last line from a crash: skip it, keep everything else
91
+ }
92
+ if (e.type === "session")
93
+ header = e;
94
+ else if (e.type === "message")
95
+ s.raw.push(e.message);
96
+ else if (e.type === "model")
97
+ s.model = e.model;
98
+ else if (e.type === "system") {
99
+ const { type: _t, ts: _ts, ...snap } = e;
100
+ if (snap.text !== undefined || s.system?.keepFrom === snap.keepFrom)
101
+ s.system = { ...s.system, ...snap };
102
+ }
103
+ else if (e.type === "turn")
104
+ s.lastTurn = { ts: e.ts, reason: e.reason, detail: e.detail };
105
+ else if (e.type === "change")
106
+ s.changes.push({ ts: e.ts, tool: e.tool, diff: e.diff });
107
+ else if (e.type === "delegation")
108
+ s.delegations.push({ id: e.id, ts: e.ts, goal: e.goal, plan: e.plan, results: [] });
109
+ else if (e.type === "agent_end")
110
+ s.delegations.find((d) => d.id === e.delegation)?.results.push(e.result);
111
+ else if (e.type === "compaction") {
112
+ s.summary = e.summary;
113
+ s.keepFrom = e.keepFrom;
114
+ s.files = e.files ?? { read: [], modified: [] };
115
+ s.request = e.request;
116
+ }
117
+ }
118
+ if (!header)
119
+ throw new Error(`Not an OAKODE session file: ${file}`);
120
+ if (header.cwd !== expectedCwd) {
121
+ throw new Error(`Session ${header.id} belongs to ${header.cwd}, not ${expectedCwd}. Run oakode from that directory to resume it.`);
122
+ }
123
+ s.id = header.id;
124
+ s.cwd = header.cwd;
125
+ s.needsNewline = text.length > 0 && !text.endsWith("\n"); // a crash left a torn last line: don't glue the next entry to it
126
+ s.acquire();
127
+ return s;
128
+ }
129
+ /**
130
+ * Sessions recorded for exactly this directory, newest first. Only the `limit` newest files are opened, and only their
131
+ * first 128 KB is read (header + first prompt); message counts are exact for files up to 2 MB, else unknown.
132
+ */
133
+ static list(cwd, limit = Infinity) {
134
+ const dir = Session.dir(cwd);
135
+ let names = [];
136
+ try {
137
+ names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
138
+ }
139
+ catch { }
140
+ const files = names
141
+ .map((n) => {
142
+ try {
143
+ const st = statSync(join(dir, n));
144
+ return { n, file: join(dir, n), mtime: st.mtime, size: st.size };
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ })
150
+ .filter((x) => x !== null)
151
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
152
+ const out = [];
153
+ for (const f of files) {
154
+ if (out.length >= limit)
155
+ break;
156
+ let head;
157
+ try {
158
+ const fd = openSync(f.file, "r");
159
+ try {
160
+ const buf = Buffer.alloc(Math.min(f.size, 128 * 1024));
161
+ readSync(fd, buf, 0, buf.length, 0);
162
+ head = buf.toString("utf8");
163
+ }
164
+ finally {
165
+ closeSync(fd);
166
+ }
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ const lines = head.split("\n");
172
+ let created;
173
+ try {
174
+ const h = JSON.parse(lines[0]);
175
+ if (h.cwd !== cwd)
176
+ continue;
177
+ if (h.created && !Number.isNaN(Date.parse(h.created)))
178
+ created = new Date(h.created);
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ let preview = "(empty)";
184
+ const prompts = [];
185
+ for (const l of lines) {
186
+ if (prompts.length >= 6)
187
+ break;
188
+ if (!l.includes('"type":"message"'))
189
+ continue;
190
+ try {
191
+ const m = JSON.parse(l).message;
192
+ if (m.role === "user" && !m.content.startsWith("[OAKODE notice]")) {
193
+ const t = m.content.replace(/\s+/g, " ");
194
+ if (!prompts.length)
195
+ preview = t.slice(0, 70);
196
+ prompts.push(t.slice(0, 300));
197
+ }
198
+ }
199
+ catch { }
200
+ }
201
+ let messages;
202
+ if (f.size <= 2 * 1024 * 1024) {
203
+ try {
204
+ messages = readFileSync(f.file, "utf8").split('"type":"message"').length - 1;
205
+ }
206
+ catch { }
207
+ }
208
+ out.push({ file: f.file, id: f.n.replace(/\.jsonl$/, "").split("_").pop(), mtime: f.mtime, created, cwd, preview, prompts, messages });
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Append durably (fsync). If the disk refuses, the entries stay in memory and are retried on the next write:
214
+ * the conversation continues, and the user is told once that it is not being saved.
215
+ */
216
+ write(e) {
217
+ this.backlog.push(JSON.stringify(e) + "\n");
218
+ // only the header, the model and notices wait; any real content (a message, a delegation, …) makes the file
219
+ if (this.deferred && (e.type === "session" || e.type === "model" || e.type === "notice" || e.type === "system"))
220
+ return;
221
+ this.deferred = false;
222
+ this.flush();
223
+ }
224
+ /** Not written to disk until its first message (see create). */
225
+ deferred = false;
226
+ flush() {
227
+ if (!this.backlog.length || this.deferred)
228
+ return true;
229
+ let pre = 0;
230
+ try {
231
+ try {
232
+ pre = statSync(this.file).size;
233
+ }
234
+ catch { }
235
+ const fd = openSync(this.file, "a", 0o600);
236
+ try {
237
+ writeSync(fd, (this.needsNewline ? "\n" : "") + this.backlog.join(""));
238
+ fsyncSync(fd);
239
+ }
240
+ finally {
241
+ closeSync(fd);
242
+ }
243
+ this.backlog = [];
244
+ this.needsNewline = false;
245
+ if (this.persistError) {
246
+ this.persistError = undefined;
247
+ this.onPersist?.("recovered");
248
+ }
249
+ return true;
250
+ }
251
+ catch (err) {
252
+ // roll back a partial append so the retry cannot duplicate entries
253
+ try {
254
+ truncateSync(this.file, pre);
255
+ }
256
+ catch {
257
+ this.needsNewline = true;
258
+ }
259
+ const msg = err.code ?? err.message;
260
+ const first = !this.persistError;
261
+ this.persistError = String(msg);
262
+ if (first)
263
+ this.onPersist?.("failed", String(msg));
264
+ return false;
265
+ }
266
+ }
267
+ /** Last resort at exit: unsaved entries go to a temp file so the conversation is not lost silently. */
268
+ emergencyDump() {
269
+ if (!this.backlog.length || this.deferred)
270
+ return undefined;
271
+ const f = join(tmpdir(), `oakode-unsaved-${this.id}-${Date.now()}.jsonl`);
272
+ try {
273
+ writeFileSync(f, this.backlog.join(""), { mode: 0o600 });
274
+ return f;
275
+ }
276
+ catch {
277
+ return undefined;
278
+ }
279
+ }
280
+ // ---- one OAKODE process per session file ----
281
+ acquire() {
282
+ const lock = this.file + ".lock";
283
+ try {
284
+ mkdirSync(join(this.file, ".."), { recursive: true });
285
+ const fd = openSync(lock, "wx", 0o600);
286
+ writeSync(fd, String(process.pid));
287
+ closeSync(fd);
288
+ }
289
+ catch (err) {
290
+ if (err.code !== "EEXIST")
291
+ return; // cannot lock (read-only dir?): proceed unlocked
292
+ const pid = Number(readFileSync(lock, "utf8").trim());
293
+ if (pid && pid !== process.pid && alive(pid))
294
+ throw new Error(`Session ${this.id} is open in another OAKODE process (pid ${pid}). Close it first, or start a new session.`);
295
+ writeFileSync(lock, String(process.pid), { mode: 0o600 }); // stale or our own
296
+ }
297
+ this.lockPath = lock;
298
+ held.add(this);
299
+ }
300
+ release() {
301
+ held.delete(this);
302
+ if (!this.lockPath)
303
+ return;
304
+ try {
305
+ if (Number(readFileSync(this.lockPath, "utf8").trim()) === process.pid)
306
+ unlinkSync(this.lockPath);
307
+ }
308
+ catch { }
309
+ this.lockPath = undefined;
310
+ }
311
+ /** Become another session (used when switching sessions) without losing lock bookkeeping. */
312
+ adopt(other) {
313
+ this.release();
314
+ held.delete(other);
315
+ Object.assign(this, other);
316
+ if (this.lockPath)
317
+ held.add(this);
318
+ }
319
+ /** How the last run ended, for the resume notice. */
320
+ interruption() {
321
+ const tail = this.raw.slice(this.keepFrom);
322
+ const synthesized = repair(tail).filter((m) => m.role === "tool" && m.content === INTERRUPTED).length - tail.filter((m) => m.role === "tool" && m.content === INTERRUPTED).length;
323
+ return { danglingCalls: synthesized, lastRole: this.context().at(-1)?.role };
324
+ }
325
+ add(message) {
326
+ this.raw.push(message);
327
+ this.write({ type: "message", ts: new Date().toISOString(), message });
328
+ }
329
+ recordChange(tool, diff) {
330
+ const ts = new Date().toISOString();
331
+ this.changes.push({ ts, tool, diff });
332
+ this.write({ type: "change", ts, tool, diff });
333
+ }
334
+ recordTurnEnd(reason, detail, requests) {
335
+ const ts = new Date().toISOString();
336
+ this.lastTurn = { ts, reason, detail };
337
+ this.write({ type: "turn", ts, reason, ...(detail ? { detail: detail.slice(0, 2000) } : {}), model: this.model, ...(requests !== undefined ? { requests } : {}) });
338
+ }
339
+ /** One request's usage as OICKI reported it (cache, reasoning, credits), for later analysis of where tokens went. */
340
+ recordUsage(usage, model, ms) {
341
+ this.write({ type: "usage", ts: new Date().toISOString(), model, ...(ms !== undefined ? { ms } : {}), usage });
342
+ }
343
+ recordDelegation(d) {
344
+ const ts = new Date().toISOString();
345
+ this.delegations.push({ id: d.id, ts, goal: d.goal, plan: d.plan, results: [] });
346
+ this.write({ type: "delegation", ts, approved: "user", ...d });
347
+ }
348
+ recordAgentEnd(delegation, result) {
349
+ this.delegations.find((d) => d.id === delegation)?.results.push(result);
350
+ this.write({ type: "agent_end", ts: new Date().toISOString(), delegation, result });
351
+ }
352
+ /** Delegations whose agents did not all end (the session stopped while they ran). */
353
+ interruptedDelegations() {
354
+ return this.delegations
355
+ .filter((d) => d.results.length < d.plan.agents.length)
356
+ .map((d) => ({ id: d.id, goal: d.goal, done: d.results.filter((r) => r.status === "done").length, total: d.plan.agents.length }));
357
+ }
358
+ recordNotice(text) {
359
+ this.write({ type: "notice", ts: new Date().toISOString(), text: text.slice(0, 2000) });
360
+ }
361
+ setSystem(snap) {
362
+ const same = this.system?.keepFrom === snap.keepFrom && this.system.text === snap.text;
363
+ this.system = snap;
364
+ this.write({ type: "system", ts: new Date().toISOString(), ...snap, ...(same ? { text: undefined } : {}) });
365
+ }
366
+ setModel(model) {
367
+ this.model = model;
368
+ this.write({ type: "model", ts: new Date().toISOString(), model });
369
+ }
370
+ compact(summary, keepFrom, files, request) {
371
+ this.summary = summary;
372
+ this.keepFrom = keepFrom;
373
+ this.files = files;
374
+ this.request = request;
375
+ this.lastPrompt = undefined;
376
+ this.write({ type: "compaction", ts: new Date().toISOString(), summary, keepFrom, files, request });
377
+ }
378
+ /** The synthetic first message that stands in for the compacted history. */
379
+ summaryMessage() {
380
+ if (!this.summary)
381
+ return undefined;
382
+ const list = (title, xs) => (xs.length ? `\n\n<${title}>\n${xs.slice(0, 100).join("\n")}${xs.length > 100 ? `\n… and ${xs.length - 100} more` : ""}\n</${title}>` : "");
383
+ return {
384
+ role: "user",
385
+ content: `[Summary of the earlier conversation, which was compacted to save context]\n\n${this.summary}` +
386
+ (this.request ? `\n\n## Request in progress (verbatim)\n${this.request}` : "") +
387
+ list("read-files", this.files.read) +
388
+ list("modified-files", this.files.modified),
389
+ };
390
+ }
391
+ /** The messages to send: summary + repaired tail. */
392
+ context() {
393
+ const tail = repair(this.raw.slice(this.keepFrom));
394
+ const head = this.summaryMessage();
395
+ return head ? [head, ...tail] : tail;
396
+ }
397
+ /** What is actually sent: like `context()`, but with old bulky tool traffic shrunk (see mask.ts). The stored history is untouched. */
398
+ requestContext() {
399
+ const tail = repair(maskTail(this.raw.slice(this.keepFrom)));
400
+ const head = this.summaryMessage();
401
+ return head ? [head, ...tail] : tail;
402
+ }
403
+ /** Estimated size of the next request (excluding system prompt/tools). */
404
+ estimateTokens() {
405
+ if (this.lastPrompt) {
406
+ return this.lastPrompt.tokens + this.raw.slice(this.lastPrompt.rawLen).reduce((n, m) => n + estimateTokens(m), 0);
407
+ }
408
+ return this.requestContext().reduce((n, m) => n + estimateTokens(m), 0);
409
+ }
410
+ firstPrompt() {
411
+ const m = this.raw.find((x) => x.role === "user");
412
+ return m?.role === "user" ? m.content : undefined;
413
+ }
414
+ }
415
+ /**
416
+ * Make a message list valid for the API: every assistant tool call gets exactly one tool result
417
+ * right after it (synthesized when the run was interrupted), and orphan tool messages are dropped.
418
+ */
419
+ export function repair(messages) {
420
+ const out = [];
421
+ for (let i = 0; i < messages.length; i++) {
422
+ const m = messages[i];
423
+ if (m.role === "tool")
424
+ continue; // orphan: its assistant message was compacted away or never saved
425
+ out.push(m);
426
+ if (m.role !== "assistant" || !m.tool_calls?.length)
427
+ continue;
428
+ const results = new Map();
429
+ let j = i + 1;
430
+ for (; j < messages.length && messages[j].role === "tool"; j++) {
431
+ const t = messages[j];
432
+ if (!results.has(t.tool_call_id))
433
+ results.set(t.tool_call_id, t);
434
+ }
435
+ for (const c of m.tool_calls) {
436
+ out.push(results.get(c.id) ?? { role: "tool", tool_call_id: c.id, content: INTERRUPTED });
437
+ }
438
+ i = j - 1;
439
+ }
440
+ return out;
441
+ }
442
+ const held = new Set();
443
+ process.on("exit", () => [...held].forEach((x) => x.release()));
444
+ const alive = (pid) => {
445
+ try {
446
+ process.kill(pid, 0);
447
+ return true;
448
+ }
449
+ catch (e) {
450
+ return e.code === "EPERM";
451
+ }
452
+ };
package/dist/shell.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The shell that runs the agent's commands. Models write POSIX shell, so on Windows Git Bash is used when it is
3
+ * installed (it is part of Git for Windows); otherwise cmd.exe, and the model is told so. Never the WSL launcher in
4
+ * System32 (bash.exe there starts a Linux distribution, if any, with a different filesystem). OAKODE_SHELL overrides.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ let cached;
9
+ export function gitBash(env = process.env) {
10
+ const roots = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LocalAppData && join(env.LocalAppData, "Programs"), "C:\\Program Files"].filter(Boolean);
11
+ for (const r of roots) {
12
+ const p = join(r, "Git", "bin", "bash.exe");
13
+ if (existsSync(p))
14
+ return p;
15
+ }
16
+ return undefined;
17
+ }
18
+ export function shell(env = process.env, platform = process.platform) {
19
+ if (cached && env === process.env && platform === process.platform)
20
+ return cached;
21
+ let s;
22
+ const override = env.OAKODE_SHELL;
23
+ if (override) {
24
+ const cmd = /^(cmd)(\.exe)?$/i.test(basename(override));
25
+ s = { bin: override, args: (c) => (cmd ? ["/d", "/s", "/c", `"${c}"`] : ["-c", c]), verbatim: cmd, name: basename(override), posix: !cmd };
26
+ }
27
+ else if (platform === "win32") {
28
+ const bash = gitBash(env);
29
+ s = bash
30
+ ? { bin: bash, args: (c) => ["-c", c], name: "bash (Git for Windows)", posix: true }
31
+ : { bin: env.ComSpec || "cmd.exe", args: (c) => ["/d", "/s", "/c", `"${c}"`], verbatim: true, name: "cmd.exe (install Git for Windows to get bash)", posix: false };
32
+ }
33
+ else {
34
+ const sh = env.SHELL?.endsWith("bash") || env.SHELL?.endsWith("zsh") ? env.SHELL : "/bin/sh";
35
+ s = { bin: sh, args: (c) => ["-c", c], name: basename(sh), posix: true };
36
+ }
37
+ if (env === process.env && platform === process.platform)
38
+ cached = s;
39
+ return s;
40
+ }