@ashrlabs/ashr 0.0.0-stage → 0.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.
package/dist/ashr.js ADDED
@@ -0,0 +1,4604 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { existsSync as existsSync7 } from "node:fs";
5
+ import { dirname as dirname5, join as join9, resolve as resolve9 } from "node:path";
6
+ import { ProcessTerminal as ProcessTerminal2 } from "@earendil-works/pi-tui";
7
+
8
+ // package.json
9
+ var package_default = {
10
+ name: "@ashrlabs/ashr",
11
+ version: "0.1.0",
12
+ description: "Ashr terminal agent for post-training models",
13
+ repository: {
14
+ type: "git",
15
+ url: "git+https://github.com/ashrlabs/manifold.git",
16
+ directory: "cli"
17
+ },
18
+ type: "module",
19
+ engines: {
20
+ node: ">=22.19.0"
21
+ },
22
+ bin: {
23
+ ashr: "dist/ashr.js"
24
+ },
25
+ files: [
26
+ "dist"
27
+ ],
28
+ scripts: {
29
+ build: "node scripts/build.mjs",
30
+ typecheck: "tsc -p tsconfig.json --noEmit",
31
+ test: "vitest --run",
32
+ "test:parity": "vitest --run --config vitest.parity.config.ts",
33
+ "test:parity:browser": "node scripts/test-parity-browser.mjs"
34
+ },
35
+ dependencies: {
36
+ "@earendil-works/pi-agent-core": "0.87.1",
37
+ "@earendil-works/pi-ai": "0.87.1",
38
+ "@earendil-works/pi-coding-agent": "0.87.1",
39
+ "@earendil-works/pi-tui": "0.87.1",
40
+ "js-tiktoken": "1.0.21",
41
+ picomatch: "4.0.7",
42
+ "shell-quote": "1.12.0"
43
+ },
44
+ devDependencies: {
45
+ "@types/node": "22.19.19",
46
+ "@types/picomatch": "4.0.3",
47
+ "@types/shell-quote": "1.7.5",
48
+ "@xterm/headless": "6.0.0",
49
+ esbuild: "0.28.2",
50
+ typescript: "7.0.2",
51
+ vitest: "4.1.9"
52
+ }
53
+ };
54
+
55
+ // src/agent/models.ts
56
+ import { findEnvKeys, getProviders } from "@earendil-works/pi-ai/compat";
57
+ var PROVIDER_KEYS = {
58
+ anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
59
+ openai: ["OPENAI_API_KEY"],
60
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
61
+ openrouter: ["OPENROUTER_API_KEY"],
62
+ xai: ["XAI_API_KEY"],
63
+ groq: ["GROQ_API_KEY"],
64
+ mistral: ["MISTRAL_API_KEY"],
65
+ cerebras: ["CEREBRAS_API_KEY"]
66
+ };
67
+ function piProviderKeys() {
68
+ const everything = new Proxy({}, { get: () => "set", has: () => true });
69
+ return getProviders().flatMap((provider) => findEnvKeys(provider, everything) ?? []);
70
+ }
71
+ var SHELL_CREDENTIALS = /* @__PURE__ */ new Set(["HF_TOKEN"]);
72
+ var SECRET_NAMES = [.../* @__PURE__ */ new Set([...Object.values(PROVIDER_KEYS).flat(), ...piProviderKeys(), "ASHR_API_KEY"])].filter((name) => !SHELL_CREDENTIALS.has(name));
73
+ var PREFERRED = [
74
+ "anthropic/claude-opus-5-5",
75
+ "anthropic/claude-fable-5-1",
76
+ "anthropic/claude-sonnet-5-5",
77
+ "anthropic/claude-opus-5",
78
+ "openai/gpt-5",
79
+ "google/gemini-3.1-pro-preview"
80
+ ];
81
+ var ModelError = class extends Error {
82
+ };
83
+ function hasKey(provider, env) {
84
+ return (PROVIDER_KEYS[provider] ?? []).some((name) => Boolean(env[name]));
85
+ }
86
+ function parse(model) {
87
+ const slash = model.indexOf("/");
88
+ if (slash <= 0 || slash === model.length - 1) throw new ModelError(`"${model}" is not a model name. Use provider/model, for example anthropic/claude-opus-5-5.`);
89
+ return { provider: model.slice(0, slash), id: model.slice(slash + 1) };
90
+ }
91
+ function pickModel(runtime, opts) {
92
+ const env = opts.env ?? process.env;
93
+ if (opts.requested) {
94
+ const { provider, id } = parse(opts.requested);
95
+ const model = runtime.getModel(provider, id);
96
+ if (model) return { model, label: opts.requested };
97
+ const known = runtime.getModels(provider).slice(0, 8).map((m) => `${m.provider}/${m.id}`);
98
+ const choices = known.length ? ` Some ${provider} models: ${known.join(", ")}.` : "";
99
+ throw new ModelError(`Unknown model "${opts.requested}". Pick one with /model or --model.${choices}`);
100
+ }
101
+ for (const label of PREFERRED) {
102
+ const { provider, id } = parse(label);
103
+ if (!hasKey(provider, env)) continue;
104
+ const model = runtime.getModel(provider, id);
105
+ if (model) return { model, label };
106
+ }
107
+ throw new ModelError("No model provider key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY, GEMINI_API_KEY), or run: ashr login --provider anthropic");
108
+ }
109
+
110
+ // src/args.ts
111
+ var USAGE = `Usage:
112
+ ashr open your hosted Manifold workspace
113
+ ashr --local start a local agent in this repo
114
+ ashr --task <name> select a hosted task
115
+ ashr --chat <id> open an existing hosted chat
116
+ ashr --service require a Manifold service key (never opens a browser)
117
+ --api-url <url> select the API deployment
118
+ --dashboard-url <url> select the dashboard used for browser sign-in
119
+ ashr -c, --continue continue the latest session here
120
+ ashr -r, --resume pick a session to resume
121
+ ashr -p "<prompt>" run one turn and print the answer
122
+ --mode ask|edit|auto local permission mode (default from settings, else auto)
123
+ --allow "<rule>" allow one more rule for this run (repeatable)
124
+ --output text|json how to print the result
125
+ ashr --local --model provider/id use this local model (hosted: /model)
126
+ ashr login [--key ask_\u2026] [--api-url <url>] sign in (browser, or a service key)
127
+ ashr login --provider anthropic [--key sk-\u2026] store a model provider key
128
+ ashr logout [--provider <name>]
129
+ ashr --version | --help`;
130
+ var UsageError = class extends Error {
131
+ constructor(message) {
132
+ super(`${message}
133
+
134
+ ${USAGE}`);
135
+ }
136
+ };
137
+ function parseArgs(argv) {
138
+ const a = { command: "run", runtime: "hosted", service: false, continue: false, resume: false, allow: [], output: "text" };
139
+ let runtime;
140
+ const rest = [...argv];
141
+ if (rest[0] === "login" || rest[0] === "logout") a.command = rest.shift();
142
+ const value = (flag) => {
143
+ const v = rest.shift();
144
+ if (v === void 0 || v.startsWith("-") && v.length > 1) throw new UsageError(`${flag} needs a value.`);
145
+ return v;
146
+ };
147
+ while (rest.length) {
148
+ const flag = rest.shift();
149
+ switch (flag) {
150
+ case "--local":
151
+ case "--hosted": {
152
+ const selected = flag === "--local" ? "local" : "hosted";
153
+ if (runtime && runtime !== selected) throw new UsageError("Choose either --local or --hosted.");
154
+ runtime = selected;
155
+ break;
156
+ }
157
+ case "--service":
158
+ a.service = true;
159
+ break;
160
+ case "--task":
161
+ a.task = value(flag);
162
+ break;
163
+ case "--chat":
164
+ a.chat = value(flag);
165
+ break;
166
+ case "--dashboard-url":
167
+ a.dashboardUrl = value(flag);
168
+ break;
169
+ case "-h":
170
+ case "--help":
171
+ a.command = "help";
172
+ break;
173
+ case "-v":
174
+ case "--version":
175
+ a.command = "version";
176
+ break;
177
+ case "-c":
178
+ case "--continue":
179
+ a.continue = true;
180
+ break;
181
+ case "-r":
182
+ case "--resume":
183
+ a.resume = true;
184
+ break;
185
+ case "-p":
186
+ case "--print":
187
+ a.print = value(flag);
188
+ break;
189
+ case "--model":
190
+ a.model = value(flag);
191
+ break;
192
+ case "--allow":
193
+ a.allow.push(value(flag));
194
+ break;
195
+ case "--key":
196
+ a.key = value(flag);
197
+ break;
198
+ case "--api-url":
199
+ a.apiUrl = value(flag);
200
+ break;
201
+ case "--provider":
202
+ a.provider = value(flag);
203
+ break;
204
+ case "--mode": {
205
+ const m = value(flag);
206
+ if (m !== "ask" && m !== "edit" && m !== "auto") throw new UsageError(`--mode must be ask, edit or auto, not "${m}".`);
207
+ a.mode = m;
208
+ break;
209
+ }
210
+ case "--output": {
211
+ const o = value(flag);
212
+ if (o !== "text" && o !== "json") throw new UsageError(`--output must be text or json, not "${o}".`);
213
+ a.output = o;
214
+ break;
215
+ }
216
+ default:
217
+ throw new UsageError(`Unknown option ${flag}.`);
218
+ }
219
+ }
220
+ const localOptions = a.print !== void 0 || a.continue || a.resume || a.model !== void 0 || a.mode !== void 0 || a.allow.length > 0;
221
+ a.runtime = runtime ?? (localOptions ? "local" : "hosted");
222
+ if (a.command === "run") {
223
+ if (a.runtime === "hosted" && localOptions) throw new UsageError("Print, resume, model and permission flags apply to --local sessions only. Use /model within hosted chats.");
224
+ if (a.runtime === "local" && (a.task || a.chat || a.service)) throw new UsageError("Task, chat and service selection apply to hosted sessions only.");
225
+ }
226
+ return a;
227
+ }
228
+
229
+ // src/platform/credentials.ts
230
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
231
+ import { dirname } from "node:path";
232
+
233
+ // src/paths.ts
234
+ import { createHash } from "node:crypto";
235
+ import { homedir } from "node:os";
236
+ import { basename, join, resolve } from "node:path";
237
+ function ashrHome(env = process.env) {
238
+ return env.ASHR_HOME ? resolve(env.ASHR_HOME) : join(homedir(), ".ashr");
239
+ }
240
+ function repoSlug(repo) {
241
+ const abs = resolve(repo);
242
+ const name = basename(abs).replace(/[^A-Za-z0-9._-]/g, "_") || "root";
243
+ return `${name}-${createHash("sha256").update(abs).digest("hex").slice(0, 8)}`;
244
+ }
245
+ function ashrPaths(env = process.env) {
246
+ const home = ashrHome(env);
247
+ return {
248
+ home,
249
+ credentials: join(home, "credentials.json"),
250
+ settings: join(home, "settings.json"),
251
+ trust: join(home, "trust.json"),
252
+ agentDir: join(home, "agent"),
253
+ memory: join(home, "memory.md"),
254
+ sessions: (org, repo) => join(home, "sessions", org, repoSlug(repo))
255
+ };
256
+ }
257
+
258
+ // src/permissions/settings.ts
259
+ import { execFileSync } from "node:child_process";
260
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
261
+ import { join as join2 } from "node:path";
262
+ function readJson(path) {
263
+ if (!existsSync(path)) return void 0;
264
+ try {
265
+ return JSON.parse(readFileSync(path, "utf8"));
266
+ } catch (err) {
267
+ throw new Error(`${path} is not valid JSON (${err.message}). Fix or delete it.`);
268
+ }
269
+ }
270
+ var MODES = /* @__PURE__ */ new Set(["ask", "edit", "auto"]);
271
+ var asMode = (m) => typeof m === "string" && MODES.has(m) ? m : void 0;
272
+ function localIsShared(repoRoot) {
273
+ try {
274
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", ".ashr/settings.local.json"], { cwd: repoRoot, stdio: "ignore" });
275
+ return true;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ function loadSettings(repoRoot, opts) {
281
+ const user = readJson(ashrPaths(opts.env).settings) ?? {};
282
+ const team = readJson(join2(repoRoot, ".ashr", "settings.json")) ?? {};
283
+ const local = readJson(join2(repoRoot, ".ashr", "settings.local.json")) ?? {};
284
+ const allow = [];
285
+ const deny = [];
286
+ const add = (file, source, withAllow) => {
287
+ for (const text of file.permissions?.deny ?? []) deny.push({ text, source });
288
+ if (withAllow) for (const text of file.permissions?.allow ?? []) allow.push({ text, source });
289
+ };
290
+ add(user, "~/.ashr/settings.json", true);
291
+ add(team, ".ashr/settings.json", opts.trusted);
292
+ const shared = localIsShared(repoRoot);
293
+ add(local, ".ashr/settings.local.json", opts.trusted || !shared);
294
+ const teamMode = (m) => opts.trusted && asMode(m) !== "auto" ? asMode(m) : void 0;
295
+ const localMode = shared ? teamMode(local.defaultMode) : asMode(local.defaultMode);
296
+ const untrusted = [...team.permissions?.allow ?? [], ...shared ? local.permissions?.allow ?? [] : []];
297
+ return {
298
+ rules: { allow, deny },
299
+ defaultMode: localMode ?? teamMode(team.defaultMode) ?? asMode(user.defaultMode) ?? "auto",
300
+ model: local.model ?? user.model,
301
+ ignored: { allow: opts.trusted ? [] : untrusted }
302
+ };
303
+ }
304
+ function addLocalRule(repoRoot, rule) {
305
+ const dir = join2(repoRoot, ".ashr");
306
+ mkdirSync(dir, { recursive: true });
307
+ const path = join2(dir, "settings.local.json");
308
+ const current = readJson(path) ?? {};
309
+ const allow = new Set(current.permissions?.allow ?? []);
310
+ allow.add(rule);
311
+ const next = { ...current, permissions: { ...current.permissions, allow: [...allow] } };
312
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}
313
+ `);
314
+ ignoreLocalFiles(repoRoot);
315
+ }
316
+ function ignoreLocalFiles(repoRoot) {
317
+ const dir = join2(repoRoot, ".ashr");
318
+ mkdirSync(dir, { recursive: true });
319
+ const ignore = join2(dir, ".gitignore");
320
+ const lines = existsSync(ignore) ? readFileSync(ignore, "utf8").split("\n").filter(Boolean) : [];
321
+ for (const want of ["settings.local.json", "data/"]) if (!lines.includes(want)) lines.push(want);
322
+ writeFileSync(ignore, `${lines.join("\n")}
323
+ `);
324
+ }
325
+ function removeLocalRule(repoRoot, rule) {
326
+ const path = join2(repoRoot, ".ashr", "settings.local.json");
327
+ const current = readJson(path);
328
+ const allow = current?.permissions?.allow ?? [];
329
+ if (!current || !allow.includes(rule)) return false;
330
+ const next = { ...current, permissions: { ...current.permissions, allow: allow.filter((r) => r !== rule) } };
331
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}
332
+ `);
333
+ return true;
334
+ }
335
+
336
+ // src/platform/credentials.ts
337
+ function readCredentials(env = process.env) {
338
+ const c = readJson(ashrPaths(env).credentials) ?? {};
339
+ return { ...c.manifold ? { manifold: c.manifold } : {}, providers: c.providers ?? {} };
340
+ }
341
+ function writeCredentials(c, env = process.env) {
342
+ const path = ashrPaths(env).credentials;
343
+ mkdirSync2(dirname(path), { recursive: true });
344
+ writeFileSync2(path, `${JSON.stringify(c, null, 2)}
345
+ `, { mode: 384 });
346
+ }
347
+ function applyProviderKeys(c, env) {
348
+ for (const [provider, key2] of Object.entries(c.providers)) {
349
+ const names = PROVIDER_KEYS[provider];
350
+ if (!names?.length || names.some((n) => env[n])) continue;
351
+ env[names[0]] = key2;
352
+ }
353
+ }
354
+
355
+ // src/platform/manifold.ts
356
+ var DEFAULT_API_URL = "https://api.manifold.ashr.io";
357
+ var KeyRejectedError = class extends Error {
358
+ constructor(message, status) {
359
+ super(message);
360
+ this.status = status;
361
+ this.name = "KeyRejectedError";
362
+ }
363
+ status;
364
+ };
365
+ async function validateKey(apiUrl, key2, fetchImpl = fetch) {
366
+ let res;
367
+ try {
368
+ res = await fetchImpl(`${apiUrl.replace(/\/$/, "")}/v1/tasks`, { headers: { Authorization: `Bearer ${key2}` } });
369
+ } catch (err) {
370
+ throw new Error(`Couldn't reach ${apiUrl}: ${err.message}. Check the address and your connection.`);
371
+ }
372
+ if (res.status === 401 || res.status === 403) throw new KeyRejectedError(`That key was rejected by ${apiUrl}. Create one in the Manifold dashboard under API keys.`, res.status);
373
+ if (!res.ok) throw new KeyRejectedError(`Manifold answered ${res.status} at ${apiUrl}. Try again, or check the address.`, res.status);
374
+ }
375
+
376
+ // src/print.ts
377
+ import { relative as relative4, resolve as resolve5 } from "node:path";
378
+
379
+ // src/agent/session.ts
380
+ import { existsSync as existsSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync3 } from "node:fs";
381
+ import { join as join5 } from "node:path";
382
+ import {
383
+ createAgentSessionFromServices,
384
+ createAgentSessionServices,
385
+ ModelRuntime,
386
+ SessionManager,
387
+ SettingsManager
388
+ } from "@earendil-works/pi-coding-agent";
389
+
390
+ // ../operator/src/context-budget.mjs
391
+ import { closeSync, mkdirSync as mkdirSync3, openSync, readSync, writeFileSync as writeFileSync3 } from "node:fs";
392
+ import { Tiktoken } from "js-tiktoken/lite";
393
+ import o200k from "js-tiktoken/ranks/o200k_base";
394
+ var encoder;
395
+ var maxTokenBytes;
396
+ var openAIProviders = /* @__PURE__ */ new Set(["openai", "openai-codex", "azure"]);
397
+ var TOKENIZER_CHUNK_SIZE = 1024;
398
+ function tokenizer(model) {
399
+ if (!openAIProviders.has(model?.provider) || !/^(?:gpt-(?:4o|4\.[15]|[56](?:[.-]|$))|o[134](?:[.-]|$)|chatgpt-4o)/.test(model.id)) return null;
400
+ if (!encoder) {
401
+ encoder = new Tiktoken(o200k);
402
+ maxTokenBytes = 1;
403
+ for (const bytes of encoder.textMap.values()) maxTokenBytes = Math.max(maxTokenBytes, bytes.length);
404
+ }
405
+ return encoder;
406
+ }
407
+ function encodeText(text, model = void 0) {
408
+ const encoding = tokenizer(model);
409
+ const chunks = [];
410
+ const append = (text2, bpe) => chunks.push({ tokens: bpe ? encoding.encode(text2, [], []) : Buffer.from(text2, "utf8"), bpe });
411
+ if (encoding) {
412
+ let pending = "";
413
+ for (const [run] of text.matchAll(/\s+|\S+/gu)) {
414
+ if (pending.length + run.length > TOKENIZER_CHUNK_SIZE) {
415
+ if (pending) append(pending, true);
416
+ pending = "";
417
+ }
418
+ if (run.length > TOKENIZER_CHUNK_SIZE) append(run, false);
419
+ else pending += run;
420
+ }
421
+ if (pending) append(pending, true);
422
+ } else append(text, false);
423
+ const length = chunks.reduce((sum, chunk) => sum + chunk.tokens.length, 0);
424
+ return {
425
+ length,
426
+ slice: (start = 0, end = length) => {
427
+ start = start < 0 ? Math.max(0, length + start) : Math.min(start, length);
428
+ end = end < 0 ? Math.max(0, length + end) : Math.min(end, length);
429
+ const pieces = [];
430
+ let offset2 = 0;
431
+ for (const { tokens, bpe } of chunks) {
432
+ if (offset2 >= end) break;
433
+ if (offset2 + tokens.length > start) {
434
+ const slice = tokens.slice(Math.max(0, start - offset2), Math.max(0, end - offset2));
435
+ pieces.push(bpe ? Buffer.concat(slice.map((token) => encoding.textMap.get(token))) : slice);
436
+ }
437
+ offset2 += tokens.length;
438
+ }
439
+ return trimEnd(trimStart(Buffer.concat(pieces))).toString("utf8");
440
+ }
441
+ };
442
+ }
443
+ var estimateTokens = (text, model = void 0) => encodeText(text, model).length;
444
+ var tokenByteCapacity = (tokens, model = void 0) => Math.floor(tokens) * (tokenizer(model) ? maxTokenBytes : 1);
445
+ var REPLY_FLOOR_TOKENS = 4096;
446
+ function replyReserve(messages = []) {
447
+ let largest = 0;
448
+ for (const message of messages) if (message?.role === "assistant") largest = Math.max(largest, message.usage?.output ?? 0);
449
+ return Math.max(REPLY_FLOOR_TOKENS, largest);
450
+ }
451
+ var ContextBudget = class {
452
+ constructor({ hardFraction }) {
453
+ if (!(hardFraction > 0 && hardFraction <= 1)) throw new Error("ContextBudget needs the session's hard context fraction");
454
+ this.hardFraction = hardFraction;
455
+ this.grants = /* @__PURE__ */ new Map();
456
+ }
457
+ /** `null` when the session cannot say how full it is (no session context, or no context window). */
458
+ measure(ctx, toolCallId) {
459
+ const usage = ctx?.getContextUsage?.();
460
+ if (!usage || usage.tokens == null || !usage.contextWindow) return null;
461
+ const messages = (ctx.sessionManager?.getBranch?.() || []).filter((e) => e.type === "message").map((e) => e.message);
462
+ const appended = new Set(messages.filter((m) => m?.role === "toolResult").map((m) => m.toolCallId));
463
+ for (const id of this.grants.keys()) if (appended.has(id)) this.grants.delete(id);
464
+ const pending = [...this.grants].reduce((sum, [id, tokens]) => sum + (id === toolCallId ? 0 : tokens), 0);
465
+ const reserve = replyReserve(messages);
466
+ const limit = Math.floor(this.hardFraction * usage.contextWindow) - reserve;
467
+ return { window: usage.contextWindow, used: usage.tokens, pending, reserve, limit, available: Math.max(0, limit - usage.tokens - pending) };
468
+ }
469
+ register(toolCallId, path) {
470
+ if (!this.grants.has(toolCallId)) this.grant(toolCallId, Buffer.byteLength(compactNotice(path, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)));
471
+ }
472
+ reserve(toolCallId, ctx, bytes) {
473
+ const measured = this.measure(ctx, toolCallId);
474
+ if (measured) {
475
+ measured.available = Math.min(measured.available, bytes);
476
+ this.grant(toolCallId, measured.available);
477
+ }
478
+ return measured;
479
+ }
480
+ release(toolCallId) {
481
+ this.grants.delete(toolCallId);
482
+ }
483
+ grant(toolCallId, tokens) {
484
+ this.grants.set(toolCallId, tokens);
485
+ }
486
+ };
487
+ function countLines(buffer) {
488
+ let lines = 0;
489
+ for (let i = buffer.indexOf(10); i !== -1; i = buffer.indexOf(10, i + 1)) lines++;
490
+ return lines + (buffer.length && buffer[buffer.length - 1] !== 10 ? 1 : 0);
491
+ }
492
+ var outputFileName = (toolCallId, ext = "txt") => `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${String(toolCallId).replace(/[^A-Za-z0-9_-]/g, "_")}.${ext}`;
493
+ var readRange = (path, start, length) => {
494
+ const buffer = Buffer.alloc(length);
495
+ const fd = openSync(path, "r");
496
+ try {
497
+ return buffer.subarray(0, readSync(fd, buffer, 0, length, start));
498
+ } finally {
499
+ closeSync(fd);
500
+ }
501
+ };
502
+ var trimStart = (b) => {
503
+ let i = 0;
504
+ while (i < b.length && i < 4 && (b[i] & 192) === 128) i++;
505
+ return b.subarray(i);
506
+ };
507
+ var trimEnd = (b) => {
508
+ for (let back = 1; back <= Math.min(4, b.length); back++) {
509
+ const byte = b[b.length - back];
510
+ if ((byte & 192) === 128) continue;
511
+ const need = byte >= 240 ? 4 : byte >= 224 ? 3 : byte >= 192 ? 2 : 1;
512
+ return need > back ? b.subarray(0, b.length - back) : b;
513
+ }
514
+ return b;
515
+ };
516
+ function headAndTail(path, size, budgetBytes) {
517
+ const headBudget = Math.floor(budgetBytes / 4), tailBudget = budgetBytes - headBudget;
518
+ let head = trimEnd(readRange(path, 0, Math.min(headBudget, size)));
519
+ const headNewline = head.lastIndexOf(10);
520
+ if (headNewline !== -1) head = head.subarray(0, headNewline + 1);
521
+ const tailStart = Math.max(head.length, size - tailBudget);
522
+ let tail = trimStart(readRange(path, tailStart, size - tailStart));
523
+ let tailOffset = size - tail.length;
524
+ const tailNewline = tailOffset > 0 ? tail.indexOf(10) : -1;
525
+ if (tailNewline !== -1 && tailNewline < tail.length - 1) {
526
+ tail = tail.subarray(tailNewline + 1);
527
+ tailOffset = size - tail.length;
528
+ }
529
+ return { head, tail, tailOffset };
530
+ }
531
+ var compactNotice = (path, bytes, lines) => `[NOT SHOWN IN FULL. Full size: ${bytes} bytes, ${lines} lines. Full text: ${path}
532
+ Use read with offset/limit for the rest.]`;
533
+ function renderPartial(path, bytes, lines, measured, subject, model, encoded) {
534
+ const header = (headBytes, headLines, tailBytes, tailLines) => `[${subject.toUpperCase()} DOES NOT FIT THE CONTEXT YOU HAVE LEFT AND IS NOT SHOWN IN FULL. Full size: ${bytes} bytes, ${lines} lines. Full text: ${path}
535
+ Shown below: ${headBytes ? `the first ${headBytes} bytes (lines 1-${headLines})` : "nothing from the start"} and ${tailBytes ? `the last ${tailBytes} bytes (lines ${lines - tailLines + 1}-${lines})` : "nothing from the end"}; ${bytes - headBytes - tailBytes} bytes in between are only in the file. Context: ${measured.used} tokens used of a ${measured.window}-token window; ${measured.available} tokens were available below the session's stop line after reserving ${measured.reserve} for your reply. Use read with offset/limit, grep, or sed on the file for the rest.]`;
536
+ const middle = (omitted2) => `
537
+ [... ${omitted2} bytes omitted; see ${path} ...]
538
+ `;
539
+ const overhead = Buffer.byteLength(header(bytes, lines, bytes, 1) + "\n" + middle(bytes));
540
+ if (overhead >= measured.available) return compactNotice(path, bytes, lines);
541
+ const available = measured.available - overhead;
542
+ const headTokens = Math.floor(available / 4), tailTokens = available - headTokens;
543
+ let headText, tailText;
544
+ if (encoded) {
545
+ headText = encoded.slice(0, headTokens);
546
+ tailText = tailTokens ? encoded.slice(-tailTokens) : "";
547
+ } else {
548
+ const candidates = headAndTail(path, bytes, tokenByteCapacity(available, model));
549
+ headText = encodeText(candidates.head.toString("utf8"), model).slice(0, headTokens);
550
+ tailText = tailTokens ? encodeText(candidates.tail.toString("utf8"), model).slice(-tailTokens) : "";
551
+ }
552
+ const headNewline = headText.lastIndexOf("\n"), tailNewline = tailText.indexOf("\n");
553
+ if (headNewline !== -1) headText = headText.slice(0, headNewline + 1);
554
+ if (tailNewline !== -1 && tailNewline < tailText.length - 1) tailText = tailText.slice(tailNewline + 1);
555
+ const head = Buffer.from(headText), tail = Buffer.from(tailText);
556
+ const omitted = bytes - head.length - tail.length;
557
+ return `${header(head.length, countLines(head), tail.length, countLines(tail))}
558
+ ${headText}${omitted > 0 ? middle(omitted) : ""}${tailText}`;
559
+ }
560
+
561
+ // ../operator/src/tools/bash.mjs
562
+ import { openSync as openSync2, writeSync, closeSync as closeSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync2 } from "node:fs";
563
+ import { join as join3 } from "node:path";
564
+ import { createBashToolDefinition, createLocalBashOperations, defineTool } from "@earendil-works/pi-coding-agent";
565
+ var SECRET_ENVIRONMENT = ["ASHR_API_KEY", "MANIFOLD_PLATFORM_CONFIG"];
566
+ function scrubbedEnvironment(env = process.env, extra = []) {
567
+ const clean2 = { ...env };
568
+ for (const name of [...SECRET_ENVIRONMENT, ...extra]) delete clean2[name];
569
+ return clean2;
570
+ }
571
+ function createBashTool({ cwd, outputDir, budget, secretNames = [] }) {
572
+ const pi = createBashToolDefinition(cwd);
573
+ return defineTool({
574
+ name: "bash",
575
+ label: "bash",
576
+ description: `Execute a bash command in the current working directory. Returns stdout and stderr. The complete output of every command is saved to a file under ${outputDir}. The whole output is returned inline whenever it fits the context you have left; when it does not, the result starts with the exact byte and line counts and the file path, followed by the start and end of the output that fit. Optionally provide a timeout in seconds (no default timeout).`,
577
+ promptSnippet: pi.promptSnippet,
578
+ promptGuidelines: pi.promptGuidelines,
579
+ parameters: pi.parameters,
580
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
581
+ mkdirSync4(outputDir, { recursive: true });
582
+ const path = join3(outputDir, outputFileName(toolCallId, "log"));
583
+ budget.register(toolCallId, path);
584
+ const controller = new AbortController();
585
+ const executionSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
586
+ let fd;
587
+ let persistenceFailure = null;
588
+ let bytes = 0, lines = 0, last = 10;
589
+ const local = createLocalBashOperations();
590
+ const operations = { exec: (command, dir, options) => local.exec(command, dir, { ...options, env: scrubbedEnvironment(options.env, secretNames), onData: (chunk) => {
591
+ if (persistenceFailure) return;
592
+ try {
593
+ for (let offset2 = 0; offset2 < chunk.length; ) {
594
+ const written = writeSync(fd, chunk, offset2, chunk.length - offset2);
595
+ if (written === 0) throw new Error("Output write made no progress");
596
+ offset2 += written;
597
+ }
598
+ bytes += chunk.length;
599
+ last = chunk[chunk.length - 1] ?? last;
600
+ for (let i = chunk.indexOf(10); i !== -1; i = chunk.indexOf(10, i + 1)) lines++;
601
+ } catch (error) {
602
+ persistenceFailure = error;
603
+ controller.abort();
604
+ }
605
+ } }) };
606
+ let failure = null;
607
+ let outcome;
608
+ try {
609
+ try {
610
+ fd = openSync2(path, "w", 384);
611
+ } catch (error) {
612
+ persistenceFailure = error;
613
+ throw error;
614
+ }
615
+ outcome = await createBashToolDefinition(cwd, { operations }).execute(toolCallId, params, executionSignal, void 0, ctx);
616
+ } catch (error) {
617
+ failure = error;
618
+ } finally {
619
+ if (fd !== void 0) {
620
+ try {
621
+ closeSync2(fd);
622
+ } catch (error) {
623
+ persistenceFailure ??= error;
624
+ }
625
+ }
626
+ }
627
+ if (persistenceFailure) {
628
+ const message = `Could not persist complete command output to ${path}: ${persistenceFailure.message}. The command was stopped; the output file may be incomplete or missing.`;
629
+ budget.grant(toolCallId, estimateTokens(message, ctx?.model));
630
+ throw new Error(message);
631
+ }
632
+ if (bytes && last !== 10) lines++;
633
+ const piText = failure ? String(failure.message ?? failure) : outcome?.isError ? outcome.content.map((part) => part.text ?? "").join("") : "";
634
+ const status = piText.replace(/^\(no output\)\n\n/, "").trim();
635
+ const measured = budget.reserve(toolCallId, ctx, bytes + Buffer.byteLength(status ? `
636
+
637
+ ${status}` : "(no output)"));
638
+ let text;
639
+ if (bytes === 0) text = status ? "" : "(no output)";
640
+ else {
641
+ const statusTokens = status ? estimateTokens(`
642
+
643
+ ${status}`, ctx?.model) : 0;
644
+ const available = measured ? Math.max(0, measured.available - statusTokens) : Infinity;
645
+ if (!measured || bytes <= tokenByteCapacity(available, ctx?.model)) text = readFileSync2(path, "utf8");
646
+ const encoded = text === void 0 ? void 0 : encodeText(text, ctx?.model);
647
+ if (measured && (!encoded || encoded.length > available)) text = renderPartial(path, bytes, lines, { ...measured, available }, "This command's output", ctx?.model, encoded);
648
+ }
649
+ const result = status ? `${text ? `${text}
650
+
651
+ ` : ""}${status}` : text;
652
+ budget.grant(toolCallId, estimateTokens(result, ctx?.model));
653
+ if (failure) throw new Error(result);
654
+ return { content: [{ type: "text", text: result }], details: { fullOutputPath: path, bytes, lines }, ...outcome?.isError ? { isError: true } : {} };
655
+ }
656
+ });
657
+ }
658
+
659
+ // src/permissions/gate.ts
660
+ import { randomUUID } from "node:crypto";
661
+ import { isAbsolute as isAbsolute2, relative as relative3 } from "node:path";
662
+
663
+ // src/permissions/paths.ts
664
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
665
+ import { homedir as homedir2 } from "node:os";
666
+ import { basename as basename2, dirname as dirname2, isAbsolute, join as join4, relative, resolve as resolve2 } from "node:path";
667
+ import { fileURLToPath } from "node:url";
668
+ function expandToolPath(path, base) {
669
+ let p = path.trim();
670
+ if (p.startsWith("@")) p = p.slice(1);
671
+ if (p.startsWith("file://")) p = fileURLToPath(p);
672
+ if (p === "~") p = homedir2();
673
+ else if (p.startsWith("~/")) p = join4(homedir2(), p.slice(2));
674
+ return isAbsolute(p) ? resolve2(p) : resolve2(base, p);
675
+ }
676
+ var exists = (p) => {
677
+ try {
678
+ lstatSync(p);
679
+ return true;
680
+ } catch {
681
+ return false;
682
+ }
683
+ };
684
+ function canonical(path) {
685
+ let cur = resolve2(path);
686
+ const rest = [];
687
+ while (!exists(cur)) {
688
+ const up = dirname2(cur);
689
+ if (up === cur) break;
690
+ rest.unshift(basename2(cur));
691
+ cur = up;
692
+ }
693
+ try {
694
+ cur = realpathSync(cur);
695
+ } catch {
696
+ try {
697
+ cur = resolve2(dirname2(cur), readlinkSync(cur));
698
+ } catch {
699
+ }
700
+ }
701
+ return join4(cur, ...rest);
702
+ }
703
+ function isInside(path, dir) {
704
+ const rel = relative(canonical(dir), canonical(path));
705
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
706
+ }
707
+
708
+ // src/permissions/rules.ts
709
+ import { relative as relative2, sep } from "node:path";
710
+ import picomatch from "picomatch";
711
+ import { parse as parse2 } from "shell-quote";
712
+ var SEPARATORS = /* @__PURE__ */ new Set(["&&", "||", ";", "|", "&", "|&", ";;"]);
713
+ var REDIRECTS = /* @__PURE__ */ new Set([">", ">>", ">|", "&>", "&>>", ">&"]);
714
+ function splitShell(command) {
715
+ const complex = /\$\(|`|<\(|>\(/.test(command);
716
+ const parts = [];
717
+ const redirects = [];
718
+ for (const line2 of command.split(/\r?\n/)) {
719
+ let words = [];
720
+ let redirectNext = false;
721
+ const flush = () => {
722
+ if (words.length) parts.push(words.join(" "));
723
+ words = [];
724
+ };
725
+ for (const tok of parse2(line2, (name) => `$${name}`)) {
726
+ if (typeof tok === "string" || "op" in tok && tok.op === "glob") {
727
+ const word = typeof tok === "string" ? tok : tok.pattern;
728
+ if (redirectNext) redirects.push(word);
729
+ else words.push(word);
730
+ redirectNext = false;
731
+ } else if ("op" in tok && SEPARATORS.has(tok.op)) flush();
732
+ else if ("op" in tok && REDIRECTS.has(tok.op)) redirectNext = true;
733
+ else if ("op" in tok) words.push(tok.op);
734
+ }
735
+ flush();
736
+ }
737
+ return { parts, complex, redirects };
738
+ }
739
+ function program(part) {
740
+ const words = part.split(" ");
741
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
742
+ if (words[0]?.includes("/")) words[0] = words[0].slice(words[0].lastIndexOf("/") + 1);
743
+ return words;
744
+ }
745
+ function shellFlags(command) {
746
+ const dangerous = /* @__PURE__ */ new Set();
747
+ const notes = /* @__PURE__ */ new Set();
748
+ const { parts, complex } = splitShell(command);
749
+ const downloads = parts.some((p) => /\b(curl|wget)\b/.test(p));
750
+ const runsShell = parts.some((p) => /^(sh|bash|zsh|source|\.)$/.test(program(p)[0] ?? ""));
751
+ if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b/.test(command) || downloads && (runsShell || complex)) dangerous.add("download into shell");
752
+ for (const part of parts) {
753
+ const [cmd = "", ...args] = program(part);
754
+ if (cmd === "sudo" || cmd === "doas") dangerous.add("sudo");
755
+ if (cmd === "git" && args.includes("push")) dangerous.add("git push");
756
+ if (cmd === "git" && args.some((a) => /^(--force|-f|--force-with-lease|--hard)$/.test(a))) dangerous.add("force");
757
+ if (/\b(curl|wget|ssh|scp|rsync|nc)\b|\bgit\s+clone\b/.test(part)) notes.add("network");
758
+ if (/^(rm|rmdir|shred)$/.test(cmd)) notes.add("deletes");
759
+ if (/\b(pip3?|npm|pnpm|yarn|brew|apt-get|apt)\s+(install|add|i)\b|\buv\s+pip\s+install\b/.test(part)) notes.add("package install");
760
+ }
761
+ return { dangerous: [...dangerous], notes: [...notes] };
762
+ }
763
+ function pathsIn(word) {
764
+ const value = /^--?[A-Za-z0-9-]+=(.+)$/.exec(word)?.[1] ?? word;
765
+ return /^(\/|~|\.\.(\/|$))|\/\.\.(\/|$)/.test(value) ? [value] : [];
766
+ }
767
+ function outsidePaths(command, repoRoot, extraDirs) {
768
+ const { parts, redirects } = splitShell(command);
769
+ const named = [...parts.flatMap((p) => p.split(" ").slice(1).flatMap(pathsIn)), ...redirects.flatMap(pathsIn)];
770
+ return named.filter((p) => {
771
+ const path = expandToolPath(p, repoRoot);
772
+ if (canonical(path).startsWith(`${sep}dev${sep}`)) return false;
773
+ return ![repoRoot, ...extraDirs].some((d) => isInside(path, d));
774
+ });
775
+ }
776
+ var READ_ONLY = /* @__PURE__ */ new Set([
777
+ "ls",
778
+ "cat",
779
+ "head",
780
+ "tail",
781
+ "wc",
782
+ "pwd",
783
+ "echo",
784
+ "printf",
785
+ "which",
786
+ "file",
787
+ "stat",
788
+ "tree",
789
+ "du",
790
+ "df",
791
+ "uname",
792
+ "whoami",
793
+ "date",
794
+ "grep",
795
+ "egrep",
796
+ "fgrep",
797
+ "rg",
798
+ "cut",
799
+ "tr",
800
+ "nl",
801
+ "diff",
802
+ "cmp",
803
+ "basename",
804
+ "dirname",
805
+ "realpath",
806
+ "readlink",
807
+ "jq",
808
+ "true",
809
+ "false",
810
+ "test"
811
+ ]);
812
+ var GIT_READ_ONLY = /* @__PURE__ */ new Set(["status", "log", "diff", "show", "rev-parse", "ls-files", "blame", "describe", "shortlog"]);
813
+ function readOnlyPart(part) {
814
+ const [cmd = "", ...args] = part.split(" ");
815
+ if (READ_ONLY.has(cmd)) return !(cmd === "rg" && args.some((a) => a.startsWith("--pre")));
816
+ if (cmd === "find") return !args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint\w*|fls)$/.test(a));
817
+ if (cmd === "git") return GIT_READ_ONLY.has(args[0] ?? "") && !args.some((a) => a.startsWith("--output"));
818
+ return /^(python3?|node|pip3?|uv|npm)$/.test(cmd) && args.length === 1 && /^(--version|-V)$/.test(args[0]);
819
+ }
820
+ function readOnlyShell(command) {
821
+ const { parts, complex, redirects } = splitShell(command);
822
+ return !complex && parts.length > 0 && redirects.every((t) => t.startsWith("/dev/") || /^&?\d$/.test(t)) && parts.every(readOnlyPart);
823
+ }
824
+ function ruleParts(text) {
825
+ const i = text.indexOf(":");
826
+ return i < 0 ? { kind: "", body: text } : { kind: text.slice(0, i), body: text.slice(i + 1) };
827
+ }
828
+ function shellRuleMatches(body, part) {
829
+ if (body === "*") return true;
830
+ if (body.endsWith(" *")) {
831
+ const prefix = body.slice(0, -2);
832
+ return part === prefix || part.startsWith(`${prefix} `);
833
+ }
834
+ return part === body;
835
+ }
836
+ function matches(rule, action, repoRoot) {
837
+ const { kind, body } = ruleParts(rule.text);
838
+ switch (action.kind) {
839
+ case "shell":
840
+ return kind === "shell" && shellRuleMatches(body, action.command);
841
+ case "edit":
842
+ return kind === "edit" && picomatch(body, { dot: true })(relative2(canonical(repoRoot), canonical(action.path)));
843
+ case "read":
844
+ return (kind === "read" || kind === "edit") && picomatch(body, { dot: true })(relative2(canonical(repoRoot), canonical(action.path)));
845
+ case "mcp": {
846
+ const [server, tool = "*"] = body.split(":");
847
+ return kind === "mcp" && server === action.server && picomatch(tool)(action.tool);
848
+ }
849
+ case "spend":
850
+ return false;
851
+ }
852
+ }
853
+ var SUBCOMMANDS = /* @__PURE__ */ new Set(["git", "npm", "pnpm", "yarn", "uv", "pip", "pip3", "docker", "kubectl", "cargo", "go", "model-ops", "make"]);
854
+ function offerForPart(part) {
855
+ const words = part.split(" ");
856
+ const [cmd = ""] = words;
857
+ if (!cmd || /^[A-Za-z_][A-Za-z0-9_]*=/.test(cmd) || /^(env|time|xargs|nohup|nice|timeout|sudo|sh|bash|zsh|eval|exec)$/.test(cmd)) return null;
858
+ const sub = words[1];
859
+ return SUBCOMMANDS.has(cmd) && sub && !sub.startsWith("-") ? `shell:${cmd} ${sub} *` : `shell:${cmd} *`;
860
+ }
861
+ function offerFor(action, repoRoot) {
862
+ switch (action.kind) {
863
+ case "edit":
864
+ return `edit:${relative2(canonical(repoRoot), canonical(action.path))}`;
865
+ case "shell": {
866
+ const { parts, complex } = splitShell(action.command);
867
+ return !complex && parts.length === 1 ? offerForPart(parts[0]) : null;
868
+ }
869
+ case "mcp":
870
+ return `mcp:${action.server}:${action.tool}`;
871
+ default:
872
+ return null;
873
+ }
874
+ }
875
+ var isUnderGit = (path, repoRoot) => relative2(canonical(repoRoot), canonical(path)).split(sep)[0]?.toLowerCase() === ".git";
876
+ function decide(action, ctx) {
877
+ const { mode, rules, repoRoot, extraDirs } = ctx;
878
+ const ask = (reason, flags2 = []) => ({ verdict: "ask", reason, offer: null, flags: flags2, needsPerson: true });
879
+ const parts = action.kind === "shell" ? splitShell(action.command).parts : [];
880
+ const deny = rules.deny.find((r) => action.kind === "shell" ? parts.some((p) => matches(r, { kind: "shell", command: p }, repoRoot)) : matches(r, action, repoRoot));
881
+ if (deny) return { verdict: "deny", reason: `blocked by rule ${deny.text} (${deny.source})`, offer: null, flags: [] };
882
+ if (action.kind === "spend") return ask("spends money on Manifold");
883
+ if ((action.kind === "read" || action.kind === "edit") && ![repoRoot, ...extraDirs].some((d) => isInside(action.path, d))) {
884
+ return ask("outside the repo", ["outside repo"]);
885
+ }
886
+ let flags = [];
887
+ if (action.kind === "shell") {
888
+ const f = shellFlags(action.command);
889
+ flags = [...f.dangerous, ...f.notes];
890
+ if (f.dangerous.length) return ask(`dangerous: ${f.dangerous.join(", ")}`, flags);
891
+ const outside = outsidePaths(action.command, repoRoot, extraDirs);
892
+ if (outside.length) return ask(`outside the repo: ${outside.join(", ")}`, [...flags, "outside repo"]);
893
+ }
894
+ if (action.kind === "edit" && mode !== "auto" && isUnderGit(action.path, repoRoot)) return ask("changes git's own files", ["git config"]);
895
+ if (action.kind === "read") return { verdict: "allow", reason: "read inside the repo", offer: null, flags };
896
+ if (action.kind === "shell" && readOnlyShell(action.command)) return { verdict: "allow", reason: "reads only, inside the repo", offer: null, flags };
897
+ if (mode === "auto") return { verdict: "allow", reason: "auto mode", offer: null, flags };
898
+ if (mode === "edit" && action.kind === "edit") return { verdict: "allow", reason: "edit mode", offer: null, flags };
899
+ if (action.kind === "shell") {
900
+ const complex = splitShell(action.command).complex;
901
+ const used = complex ? [] : parts.map((p) => rules.allow.find((r) => matches(r, { kind: "shell", command: p }, repoRoot)));
902
+ if (used.length && used.every(Boolean)) return { verdict: "allow", reason: `allowed by ${[...new Set(used.map((r) => r.text))].join(", ")}`, offer: null, flags };
903
+ const offer = offerFor(action, repoRoot);
904
+ if (offer) return { verdict: "ask", reason: `${mode} mode`, offer, flags };
905
+ const partRules = complex ? [] : parts.map(offerForPart).filter((r) => r !== null);
906
+ return { verdict: "ask", reason: `${mode} mode`, offer: null, flags, ...partRules.length === parts.length ? { partRules } : {} };
907
+ }
908
+ const allow = rules.allow.find((r) => matches(r, action, repoRoot));
909
+ if (allow) return { verdict: "allow", reason: `allowed by ${allow.text} (${allow.source})`, offer: null, flags };
910
+ return { verdict: "ask", reason: `${mode} mode`, offer: offerFor(action, repoRoot), flags };
911
+ }
912
+
913
+ // src/permissions/gate.ts
914
+ var absolute = (p, root2) => canonical(expandToolPath(String(p ?? ""), root2));
915
+ function classify(toolName, input, repoRoot) {
916
+ if (toolName === "bash") return { kind: "shell", command: String(input.command ?? "") };
917
+ if (toolName === "edit" || toolName === "write") return { kind: "edit", path: absolute(input.path, repoRoot) };
918
+ if (["read", "grep", "find", "ls"].includes(toolName)) return { kind: "read", path: absolute(input.path ?? ".", repoRoot) };
919
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
920
+ if (mcp) return { kind: "mcp", server: mcp[1], tool: mcp[2] };
921
+ return { kind: "mcp", server: "ashr", tool: toolName };
922
+ }
923
+ function editPreview(input) {
924
+ if (Array.isArray(input.edits)) {
925
+ const out = [];
926
+ for (const e of input.edits) {
927
+ for (const l of String(e.oldText ?? "").split("\n")) out.push(`- ${l}`);
928
+ for (const l of String(e.newText ?? "").split("\n")) out.push(`+ ${l}`);
929
+ }
930
+ return out;
931
+ }
932
+ return String(input.content ?? "").split("\n").map((l) => `+ ${l}`);
933
+ }
934
+ function describeAction(action, input, d, repoRoot) {
935
+ return { ...card(action, input, d, repoRoot), partRules: d.partRules ?? [], needsPerson: d.needsPerson ?? false };
936
+ }
937
+ function card(action, input, d, repoRoot) {
938
+ const shown = (p) => {
939
+ const r = relative3(canonical(repoRoot), p);
940
+ return r.startsWith("..") || isAbsolute2(r) ? p : r;
941
+ };
942
+ switch (action.kind) {
943
+ case "edit": {
944
+ const outside = d.flags.includes("outside repo");
945
+ return { kind: outside ? "outside" : "edit", title: outside ? "Write outside the repo" : "Edit", subject: shown(action.path), detail: editPreview(input), flags: d.flags, offer: d.offer };
946
+ }
947
+ case "read":
948
+ return { kind: "outside", title: "Read outside the repo", subject: action.path, detail: [], flags: d.flags, offer: null };
949
+ case "shell": {
950
+ const lines = action.command.split(/\r?\n/);
951
+ const long = lines.length > 1 || action.command.length > 60;
952
+ return { kind: "shell", title: "Shell", subject: long ? "" : action.command, detail: long ? lines : [], flags: d.flags, offer: d.offer };
953
+ }
954
+ case "mcp":
955
+ return { kind: "mcp", title: `${action.server} \xB7 ${action.tool}`, subject: JSON.stringify(input), detail: [], flags: d.flags, offer: d.offer };
956
+ case "spend":
957
+ return { kind: "spend", title: action.what, subject: "", detail: [], flags: [], offer: null };
958
+ }
959
+ }
960
+ function createGate(opts) {
961
+ return {
962
+ name: "ashr-gate",
963
+ hidden: true,
964
+ factory: (pi) => {
965
+ pi.on("tool_call", async (event, ctx) => {
966
+ const input = event.input;
967
+ const action = classify(event.toolName, input, opts.repoRoot);
968
+ const d = decide(action, { mode: opts.getMode(), rules: opts.getRules(), repoRoot: opts.repoRoot, extraDirs: opts.extraDirs() });
969
+ if (d.verdict === "deny") return { block: true, reason: `Not allowed: ${d.reason}.` };
970
+ if (d.verdict === "ask") {
971
+ const answer = await opts.approver.ask({ id: event.toolCallId || randomUUID(), ...describeAction(action, input, d, opts.repoRoot) }, ctx.signal);
972
+ if (!answer.approve) {
973
+ return { block: true, reason: answer.reason ? `The user rejected this: ${answer.reason}` : "The user rejected this action." };
974
+ }
975
+ if (answer.always && d.offer) {
976
+ addLocalRule(opts.repoRoot, d.offer);
977
+ opts.onRuleSaved?.(d.offer);
978
+ }
979
+ }
980
+ if (action.kind === "edit") opts.onBeforeWrite?.(action.path);
981
+ return void 0;
982
+ });
983
+ }
984
+ };
985
+ }
986
+
987
+ // src/agent/events.ts
988
+ function resultText(result) {
989
+ const content = result?.content;
990
+ if (!Array.isArray(content)) return "";
991
+ return content.map((c) => c.type === "text" ? c.text ?? "" : "").join("");
992
+ }
993
+ function mapEvent(e) {
994
+ switch (e.type) {
995
+ case "message_update": {
996
+ const ev = e.assistantMessageEvent;
997
+ if (ev.type === "text_delta") return [{ type: "text", delta: ev.delta }];
998
+ if (ev.type === "thinking_delta") return [{ type: "thinking" }];
999
+ return [];
1000
+ }
1001
+ case "tool_execution_start":
1002
+ return [{ type: "tool_start", id: e.toolCallId, name: e.toolName, args: e.args ?? {} }];
1003
+ case "tool_execution_end": {
1004
+ const text = resultText(e.result).trim();
1005
+ return [{ type: "tool_end", id: e.toolCallId, name: e.toolName, isError: e.isError, summary: text.split("\n")[0] ?? "", output: text }];
1006
+ }
1007
+ case "turn_end":
1008
+ return [{ type: "turn_end" }];
1009
+ case "agent_settled":
1010
+ return [{ type: "settled" }];
1011
+ case "queue_update":
1012
+ return [{ type: "queue", steering: [...e.steering], followUp: [...e.followUp] }];
1013
+ case "compaction_start":
1014
+ return [{ type: "compaction", phase: "start" }];
1015
+ case "compaction_end":
1016
+ return [{ type: "compaction", phase: "end", ...e.errorMessage ? { error: e.errorMessage } : {} }];
1017
+ case "auto_retry_start":
1018
+ return [{ type: "retry", attempt: e.attempt, max: e.maxAttempts, error: e.errorMessage }];
1019
+ case "agent_end": {
1020
+ const last = [...e.messages].reverse().find((m) => m.role === "assistant");
1021
+ if (last?.stopReason === "error" && !e.willRetry) return [{ type: "error", message: last.errorMessage ?? "The model request failed." }];
1022
+ return [];
1023
+ }
1024
+ default:
1025
+ return [];
1026
+ }
1027
+ }
1028
+
1029
+ // src/agent/session.ts
1030
+ var ASHR_SYSTEM_PROMPT = [
1031
+ "You are Ashr, a terminal agent for engineers who post-train models.",
1032
+ "You work in the user's repository on their machine. You can read, edit and write files and run shell commands; the user approves actions according to their permission mode.",
1033
+ "Be direct. Before a long or costly action, say what you will do. When the user rejects a tool call, adapt to their reason instead of retrying the same action."
1034
+ ].join("\n");
1035
+ function contextFiles(repoRoot, env) {
1036
+ return [join5(repoRoot, "ASHR.md"), ashrPaths(env).memory].filter((path) => existsSync2(path)).map((path) => ({ path, content: readFileSync3(path, "utf8") }));
1037
+ }
1038
+ function recordedModel(file, sessionDir, repoRoot) {
1039
+ const entries = SessionManager.open(file, sessionDir, repoRoot).getEntries();
1040
+ for (let i = entries.length - 1; i >= 0; i--) {
1041
+ const e = entries[i];
1042
+ if (e.type === "model_change" && e.provider && e.modelId) return `${e.provider}/${e.modelId}`;
1043
+ if (e.type === "message" && e.message?.role === "assistant" && e.message.provider && e.message.model) return `${e.message.provider}/${e.message.model}`;
1044
+ }
1045
+ return void 0;
1046
+ }
1047
+ function modelKnown(runtime, label) {
1048
+ const slash = label.indexOf("/");
1049
+ return slash > 0 && runtime.getModel(label.slice(0, slash), label.slice(slash + 1)) !== void 0;
1050
+ }
1051
+ async function createAshrSession(o) {
1052
+ const env = o.env ?? process.env;
1053
+ const paths = ashrPaths(env);
1054
+ const outputDir = join5(o.sessionDir, "outputs");
1055
+ for (const dir of [paths.agentDir, o.sessionDir]) mkdirSync5(dir, { recursive: true });
1056
+ const modelRuntime = o.modelRuntime ?? await ModelRuntime.create({ authPath: join5(paths.agentDir, "auth.json"), modelsPath: null, refreshOnCreate: false });
1057
+ const recorded = !o.model && o.sessionFile ? recordedModel(o.sessionFile, o.sessionDir, o.repoRoot) : void 0;
1058
+ let notice;
1059
+ let picked;
1060
+ if (recorded && modelKnown(modelRuntime, recorded)) picked = pickModel(modelRuntime, { requested: recorded, env: process.env });
1061
+ else {
1062
+ picked = pickModel(modelRuntime, { requested: o.model, env: process.env });
1063
+ if (recorded) notice = `Model ${recorded} is not available; using ${picked.label}. Change it with /model.`;
1064
+ }
1065
+ const { model, label } = picked;
1066
+ const settingsManager = SettingsManager.inMemory({
1067
+ compaction: { enabled: true },
1068
+ retry: { enabled: true, maxRetries: 3, baseDelayMs: Number(env.ASHR_RETRY_BASE_MS ?? 2e3), maxAgentDelayMs: 6e4 }
1069
+ });
1070
+ const services = await createAgentSessionServices({
1071
+ cwd: o.repoRoot,
1072
+ agentDir: paths.agentDir,
1073
+ settingsManager,
1074
+ modelRuntime,
1075
+ resourceLoaderOptions: {
1076
+ noExtensions: true,
1077
+ noThemes: true,
1078
+ noContextFiles: true,
1079
+ noSkills: true,
1080
+ noPromptTemplates: true,
1081
+ appendSystemPrompt: [],
1082
+ extensionFactories: [
1083
+ createGate({
1084
+ repoRoot: o.repoRoot,
1085
+ getMode: o.mode,
1086
+ getRules: o.rules,
1087
+ extraDirs: o.extraDirs,
1088
+ approver: o.approver,
1089
+ onRuleSaved: o.onRuleSaved,
1090
+ onBeforeWrite: o.onBeforeWrite
1091
+ })
1092
+ ],
1093
+ systemPrompt: ASHR_SYSTEM_PROMPT,
1094
+ agentsFilesOverride: () => ({ agentsFiles: contextFiles(o.repoRoot, env) })
1095
+ }
1096
+ });
1097
+ const sessionManager = o.sessionFile ? SessionManager.open(o.sessionFile, o.sessionDir, o.repoRoot) : SessionManager.create(o.repoRoot, o.sessionDir);
1098
+ const bash = createBashTool({ cwd: o.repoRoot, outputDir, budget: new ContextBudget({ hardFraction: 0.9 }), secretNames: SECRET_NAMES });
1099
+ const { session } = await createAgentSessionFromServices({
1100
+ services,
1101
+ sessionManager,
1102
+ model,
1103
+ tools: ["bash", "read", "edit", "write"],
1104
+ customTools: [bash]
1105
+ });
1106
+ return {
1107
+ session,
1108
+ modelRuntime,
1109
+ modelLabel: label,
1110
+ outputDir,
1111
+ ...notice ? { notice } : {},
1112
+ subscribe: (fn) => session.subscribe((e) => {
1113
+ for (const mapped of mapEvent(e)) fn(mapped);
1114
+ }),
1115
+ dispose: async () => session.dispose()
1116
+ };
1117
+ }
1118
+
1119
+ // src/permissions/trust.ts
1120
+ import { createHash as createHash2 } from "node:crypto";
1121
+ import { existsSync as existsSync3, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
1122
+ import { dirname as dirname3, join as join6, resolve as resolve4 } from "node:path";
1123
+ var read = (path) => existsSync3(path) ? readFileSync4(path, "utf8") : "";
1124
+ function repoTrustState(repoRoot, env = process.env) {
1125
+ const settingsPath = join6(repoRoot, ".ashr", "settings.json");
1126
+ const mcpPath = join6(repoRoot, ".ashr", "mcp.json");
1127
+ const localPath = join6(repoRoot, ".ashr", "settings.local.json");
1128
+ const shared = localIsShared(repoRoot);
1129
+ const content = [read(settingsPath), read(mcpPath), ...shared ? [read(localPath)] : []];
1130
+ const hash = createHash2("sha256").update(content.join("\0")).digest("hex");
1131
+ const files = [settingsPath, ...shared ? [localPath] : []].map((p) => readJson(p) ?? {});
1132
+ const mcp = readJson(mcpPath) ?? {};
1133
+ const allow = files.flatMap((f) => f.permissions?.allow ?? []);
1134
+ const servers = Object.entries(mcp.mcpServers ?? {}).map(([name, s]) => ({
1135
+ name,
1136
+ command: s.url ?? [s.command, ...s.args ?? []].filter(Boolean).join(" ")
1137
+ }));
1138
+ const needed = allow.length > 0 || servers.length > 0 || files.some((f) => f.defaultMode !== void 0);
1139
+ const store = readJson(ashrPaths(env).trust) ?? {};
1140
+ return { needed, trusted: !needed || store[resolve4(repoRoot)] === hash, hash, allow, servers };
1141
+ }
1142
+ function trustRepo(repoRoot, hash, env = process.env) {
1143
+ const path = ashrPaths(env).trust;
1144
+ const store = readJson(path) ?? {};
1145
+ store[resolve4(repoRoot)] = hash;
1146
+ mkdirSync6(dirname3(path), { recursive: true });
1147
+ writeFileSync4(path, `${JSON.stringify(store, null, 2)}
1148
+ `, { mode: 384 });
1149
+ ignoreLocalFiles(repoRoot);
1150
+ }
1151
+
1152
+ // src/print.ts
1153
+ async function runPrint(o) {
1154
+ const out = o.stdout ?? ((s2) => process.stdout.write(s2));
1155
+ const err = o.stderr ?? ((s2) => process.stderr.write(s2));
1156
+ const trust = repoTrustState(o.repoRoot, o.env);
1157
+ const settings = loadSettings(o.repoRoot, { trusted: trust.trusted, env: o.env });
1158
+ const rules = { allow: [...settings.rules.allow, ...o.allow.map((text) => ({ text, source: "--allow" }))], deny: settings.rules.deny };
1159
+ const mode = o.mode ?? settings.defaultMode;
1160
+ const needed = [];
1161
+ let s;
1162
+ try {
1163
+ s = await createAshrSession({
1164
+ repoRoot: o.repoRoot,
1165
+ sessionDir: ashrPaths(o.env).sessions(o.org ?? "default", o.repoRoot),
1166
+ modelRuntime: o.modelRuntime,
1167
+ model: o.model,
1168
+ env: o.env,
1169
+ mode: () => mode,
1170
+ rules: () => rules,
1171
+ extraDirs: () => [],
1172
+ approver: {
1173
+ ask: async (req) => {
1174
+ needed.push(req);
1175
+ return { approve: false, reason: "This needs approval and print mode cannot ask. Stop and explain what you would do." };
1176
+ }
1177
+ }
1178
+ });
1179
+ } catch (e) {
1180
+ err(`${e.message}
1181
+ `);
1182
+ return 1;
1183
+ }
1184
+ const tools = [];
1185
+ const starts = /* @__PURE__ */ new Map();
1186
+ let failure;
1187
+ s.subscribe((e) => {
1188
+ if (e.type === "tool_start") {
1189
+ const subject = e.name === "bash" ? String(e.args.command ?? "") : typeof e.args.path === "string" ? relative4(o.repoRoot, resolve5(o.repoRoot, e.args.path)) : "";
1190
+ starts.set(e.id, { name: e.name, subject });
1191
+ }
1192
+ if (e.type === "tool_end") tools.push({ ...starts.get(e.id) ?? { name: e.name, subject: "" }, isError: e.isError });
1193
+ if (e.type === "error") failure = e.message;
1194
+ });
1195
+ try {
1196
+ await s.session.prompt(o.prompt);
1197
+ } catch (e) {
1198
+ failure = e.message;
1199
+ }
1200
+ const last = [...s.session.messages].reverse().find((m) => m.role === "assistant");
1201
+ const result = (last?.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").trim();
1202
+ const stats = s.session.getSessionStats();
1203
+ await s.dispose();
1204
+ if (failure) {
1205
+ err(`${failure}
1206
+ `);
1207
+ return 1;
1208
+ }
1209
+ if (needed.length) {
1210
+ for (const req of needed) {
1211
+ const rules2 = req.offer ? [req.offer] : req.partRules ?? [];
1212
+ const allow = rules2.map((r) => `--allow "${r}"`).join(" ");
1213
+ const hint = req.needsPerson ? "this always needs a person; run ashr interactively" : `rerun with ${allow ? `${allow} or ` : ""}--mode ${req.kind === "edit" ? "edit|auto" : "auto"}`;
1214
+ err(`Needs approval in print mode: ${req.title} ${req.subject || req.detail[0] || ""}. To allow it, ${hint}.
1215
+ `);
1216
+ }
1217
+ return 3;
1218
+ }
1219
+ out(o.output === "json" ? `${JSON.stringify({ result, tools, usage: { tokens: stats.tokens.total, cost: stats.cost } })}
1220
+ ` : `${result}
1221
+ `);
1222
+ return 0;
1223
+ }
1224
+
1225
+ // src/ui/app.ts
1226
+ import { existsSync as existsSync6, statSync as statSync2 } from "node:fs";
1227
+ import { isAbsolute as isAbsolute3, relative as relative5, resolve as resolve6 } from "node:path";
1228
+ import { Container, matchesKey, SelectList, Spacer, TuiMainScreen } from "@earendil-works/pi-tui";
1229
+
1230
+ // src/agent/snapshots.ts
1231
+ import { createHash as createHash3 } from "node:crypto";
1232
+ import { existsSync as existsSync4, mkdirSync as mkdirSync7, readdirSync, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
1233
+ import { dirname as dirname4, join as join7 } from "node:path";
1234
+ var root = (dir, sessionId) => join7(dir, sessionId, "snapshots");
1235
+ var key = (path) => createHash3("sha256").update(path).digest("hex").slice(0, 16);
1236
+ function snapshotBefore(dir, sessionId, entryId, path) {
1237
+ const file = join7(root(dir, sessionId), entryId, `${key(path)}.json`);
1238
+ if (existsSync4(file)) return;
1239
+ mkdirSync7(dirname4(file), { recursive: true });
1240
+ const snap = existsSync4(path) ? { path, existed: true, content: readFileSync5(path).toString("base64") } : { path, existed: false };
1241
+ writeFileSync5(file, JSON.stringify(snap));
1242
+ }
1243
+ function snapshotsAfter(dir, sessionId, entryIds) {
1244
+ const seen = /* @__PURE__ */ new Map();
1245
+ for (const entryId of entryIds) {
1246
+ const d = join7(root(dir, sessionId), entryId);
1247
+ if (!existsSync4(d)) continue;
1248
+ for (const name of readdirSync(d)) {
1249
+ const snap = JSON.parse(readFileSync5(join7(d, name), "utf8"));
1250
+ if (seen.has(snap.path)) continue;
1251
+ seen.set(snap.path, { path: snap.path, existed: snap.existed, entryId, ...snap.content !== void 0 ? { content: Buffer.from(snap.content, "base64") } : {} });
1252
+ }
1253
+ }
1254
+ return [...seen.values()];
1255
+ }
1256
+ function restore(snaps) {
1257
+ for (const s of snaps) {
1258
+ if (s.existed) {
1259
+ mkdirSync7(dirname4(s.path), { recursive: true });
1260
+ writeFileSync5(s.path, s.content ?? Buffer.alloc(0));
1261
+ } else rmSync(s.path, { force: true });
1262
+ }
1263
+ return { restored: snaps.map((s) => s.path) };
1264
+ }
1265
+
1266
+ // src/sessions/store.ts
1267
+ import { existsSync as existsSync5, linkSync, readFileSync as readFileSync6, renameSync, statSync, unlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
1268
+ import { SessionManager as SessionManager2 } from "@earendil-works/pi-coding-agent";
1269
+ var MODE_ENTRY = "ashr-mode";
1270
+ async function listSessions(repoRoot, dir) {
1271
+ if (!existsSync5(dir)) return [];
1272
+ const infos = await SessionManager2.list(repoRoot, dir);
1273
+ return infos.map((s) => ({ file: s.path, id: s.id, name: s.name ?? s.firstMessage.split("\n")[0] ?? "", updatedAt: s.modified.getTime(), messages: s.messageCount })).sort((a, b) => b.updatedAt - a.updatedAt);
1274
+ }
1275
+ async function latestSession(repoRoot, dir) {
1276
+ return (await listSessions(repoRoot, dir))[0];
1277
+ }
1278
+ function alive(pid) {
1279
+ try {
1280
+ process.kill(pid, 0);
1281
+ return true;
1282
+ } catch (err) {
1283
+ return err.code === "EPERM";
1284
+ }
1285
+ }
1286
+ function lockHolder(file) {
1287
+ const lock = `${file}.lock`;
1288
+ if (!existsSync5(lock)) return void 0;
1289
+ const pid = Number(readFileSync6(lock, "utf8").trim());
1290
+ return Number.isInteger(pid) && pid > 0 && pid !== process.pid && alive(pid) ? pid : void 0;
1291
+ }
1292
+ function acquireLock(file) {
1293
+ const lock = `${file}.lock`;
1294
+ for (let attempt = 0; attempt < 3; attempt++) {
1295
+ if (createLock(lock)) return { release: () => releaseLock(lock) };
1296
+ const holder = livePid(lock);
1297
+ if (holder !== void 0) return { heldBy: holder };
1298
+ if (justCreated(lock)) continue;
1299
+ const stale = `${lock}.${process.pid}.stale`;
1300
+ try {
1301
+ renameSync(lock, stale);
1302
+ } catch {
1303
+ continue;
1304
+ }
1305
+ const taken = livePid(stale);
1306
+ if (taken !== void 0) {
1307
+ try {
1308
+ linkSync(stale, lock);
1309
+ } catch {
1310
+ }
1311
+ unlinkSync(stale);
1312
+ return { heldBy: taken };
1313
+ }
1314
+ unlinkSync(stale);
1315
+ }
1316
+ throw new Error(`Could not lock ${file}: another process keeps taking it.`);
1317
+ }
1318
+ function createLock(lock) {
1319
+ const tmp = `${lock}.${process.pid}.tmp`;
1320
+ writeFileSync6(tmp, String(process.pid));
1321
+ try {
1322
+ linkSync(tmp, lock);
1323
+ return true;
1324
+ } catch (err) {
1325
+ if (err.code !== "EEXIST") throw err;
1326
+ return false;
1327
+ } finally {
1328
+ unlinkSync(tmp);
1329
+ }
1330
+ }
1331
+ function justCreated(lock) {
1332
+ try {
1333
+ return readFileSync6(lock, "utf8").trim() === "" && Date.now() - statSync(lock).mtimeMs < 5e3;
1334
+ } catch {
1335
+ return false;
1336
+ }
1337
+ }
1338
+ function livePid(path) {
1339
+ let pid;
1340
+ try {
1341
+ pid = Number(readFileSync6(path, "utf8").trim());
1342
+ } catch {
1343
+ return void 0;
1344
+ }
1345
+ return Number.isInteger(pid) && pid > 0 && alive(pid) ? pid : void 0;
1346
+ }
1347
+ function releaseLock(lock) {
1348
+ try {
1349
+ if (readFileSync6(lock, "utf8").trim() === String(process.pid)) unlinkSync(lock);
1350
+ } catch {
1351
+ }
1352
+ }
1353
+ function restoredMode(saved, fallback) {
1354
+ return saved === "ask" || saved === "edit" || saved === "auto" ? saved : fallback;
1355
+ }
1356
+ function recordMode(sm, mode) {
1357
+ sm.appendCustomEntry(MODE_ENTRY, { mode });
1358
+ }
1359
+ function lastRecordedMode(sm) {
1360
+ const entries = sm.getEntries();
1361
+ for (let i = entries.length - 1; i >= 0; i--) {
1362
+ const e = entries[i];
1363
+ if (e.type === "custom" && e.customType === MODE_ENTRY) return e.data?.mode;
1364
+ }
1365
+ return void 0;
1366
+ }
1367
+
1368
+ // src/ui/commands.ts
1369
+ var COMMANDS = [
1370
+ { name: "/usage", group: "VIEWS", kind: "view", description: "tokens, context and cost for this session", run: (app) => app.showUsage() },
1371
+ { name: "/permissions", group: "VIEWS", kind: "view", description: "permission rules; /permissions remove <rule> deletes one of yours", run: (app, args) => app.showPermissions(args) },
1372
+ { name: "/resume", group: "SESSION", kind: "local", description: "open an earlier session in this repo", run: (app) => app.pickResume() },
1373
+ { name: "/fork", group: "SESSION", kind: "local", description: "branch a new session from an earlier message", run: (app) => app.pickFork() },
1374
+ { name: "/rewind", group: "SESSION", kind: "local", description: "go back to an earlier message, optionally restoring files", run: (app) => app.pickRewind() },
1375
+ { name: "/compact", group: "SESSION", kind: "local", description: "summarise the conversation to free context", run: (app) => app.compact() },
1376
+ { name: "/model", group: "SETTINGS", kind: "local", description: "switch the model", run: (app, args) => app.setModel(args) },
1377
+ { name: "/mode", group: "SETTINGS", kind: "local", description: "ask, edit or auto", run: (app, args) => app.setMode(args) },
1378
+ { name: "/add-dir", group: "SETTINGS", kind: "local", description: "let the agent use another directory this session", run: (app, args) => app.addDir(args) },
1379
+ { name: "/help", group: "SETTINGS", kind: "view", description: "commands and keys", run: (app) => app.showHelp() },
1380
+ { name: "/quit", group: "SETTINGS", kind: "local", description: "leave ashr", run: (app) => app.quit() }
1381
+ ];
1382
+ var ORDER = ["TRAINING", "VIEWS", "SESSION", "SETTINGS"];
1383
+ function menuGroups(commands, values) {
1384
+ return ORDER.map((label) => ({
1385
+ label,
1386
+ commands: commands.filter((c) => c.group === label).map((c) => ({ name: c.name, description: c.description, ...values[c.name] ? { value: values[c.name] } : {} }))
1387
+ })).filter((g) => g.commands.length > 0);
1388
+ }
1389
+
1390
+ // src/ui/components/approval.ts
1391
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
1392
+
1393
+ // src/ui/components/layout.ts
1394
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1395
+ var Block = class {
1396
+ cachedWidth = -1;
1397
+ cachedLines = [];
1398
+ render(width) {
1399
+ if (width !== this.cachedWidth) {
1400
+ this.cachedLines = this.draw(width).map((l) => fit(l, width));
1401
+ this.cachedWidth = width;
1402
+ }
1403
+ return this.cachedLines;
1404
+ }
1405
+ invalidate() {
1406
+ this.cachedWidth = -1;
1407
+ }
1408
+ changed() {
1409
+ this.cachedWidth = -1;
1410
+ }
1411
+ };
1412
+ function fit(s, width) {
1413
+ return visibleWidth(s) > width ? truncateToWidth(s, Math.max(0, width - 1), "\u2026") : s;
1414
+ }
1415
+ function line(left, right, width) {
1416
+ const r = visibleWidth(right) > width - 2 ? fit(right, Math.max(0, width - 2)) : right;
1417
+ const room = width - visibleWidth(r) - 1;
1418
+ const l = visibleWidth(left) > room ? fit(left, Math.max(0, room)) : left;
1419
+ return l + " ".repeat(Math.max(1, width - visibleWidth(l) - visibleWidth(r))) + r;
1420
+ }
1421
+ function formatTokens(n) {
1422
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1423
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
1424
+ return String(n);
1425
+ }
1426
+ function formatDuration(ms) {
1427
+ const s = Math.floor(ms / 1e3);
1428
+ const h = Math.floor(s / 3600);
1429
+ const m = Math.floor(s % 3600 / 60);
1430
+ if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
1431
+ if (m > 0) return `${m}m${String(s % 60).padStart(2, "0")}s`;
1432
+ return `${s}s`;
1433
+ }
1434
+ function clock(ms) {
1435
+ const s = Math.floor(ms / 1e3);
1436
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
1437
+ }
1438
+
1439
+ // src/ui/components/approval.ts
1440
+ var COLLAPSED_DETAIL = 12;
1441
+ var ApprovalCard = class extends Block {
1442
+ constructor(t, req) {
1443
+ super();
1444
+ this.t = t;
1445
+ this.req = req;
1446
+ }
1447
+ t;
1448
+ req;
1449
+ expanded = false;
1450
+ /** While focused, a/r/A/e answer the card; Tab hands the keyboard to the input box. */
1451
+ focused = true;
1452
+ /** Drawn under its tool row: the row already names the tool and what it touches, so the title is left out. */
1453
+ attached = false;
1454
+ toggle() {
1455
+ this.expanded = !this.expanded;
1456
+ this.changed();
1457
+ }
1458
+ setFocused(focused) {
1459
+ this.focused = focused;
1460
+ this.changed();
1461
+ }
1462
+ draw(width) {
1463
+ const { t, req } = this;
1464
+ const rail = `${this.attached ? " " : ""}${t.fg("accent", "\u2503")}`;
1465
+ const indent = this.attached ? 6 : 4;
1466
+ const label = req.kind === "spend" ? t.fg("warn", "SPEND") : t.fg("soft", "APPROVAL");
1467
+ const lines = [];
1468
+ if (!this.attached) lines.push(line(`${rail} ${t.bold(t.fg("text", req.title))} ${t.fg("text", req.subject)}`, label, width));
1469
+ const detail = this.expanded ? req.detail : req.detail.slice(0, COLLAPSED_DETAIL);
1470
+ for (const d of detail) {
1471
+ const colour = d.startsWith("- ") ? "bad" : d.startsWith("+ ") ? "ok" : "text";
1472
+ for (const part of wrapTextWithAnsi(d, Math.max(1, width - indent))) lines.push(`${rail} ${t.fg(colour, part)}`);
1473
+ }
1474
+ if (detail.length < req.detail.length) lines.push(`${rail} ${t.fg("soft", `${req.detail.length - detail.length} more lines`)}`);
1475
+ if (req.flags.length) lines.push(`${rail} ${t.fg("soft", req.flags.join(" \xB7 "))}`);
1476
+ const keys = [`${t.bold(t.fg("accent", "a"))} ${t.bold(t.fg("text", "approve"))}`, `${t.fg("accent", "r")} ${t.fg("soft", "reject")}`];
1477
+ if (req.offer) keys.push(`${t.fg("accent", "A")} ${t.fg("soft", `always allow ${req.offer}`)}`);
1478
+ if (req.detail.length > COLLAPSED_DETAIL) keys.push(`${t.fg("accent", "e")} ${t.fg("soft", this.expanded ? "collapse" : "expand")}`);
1479
+ keys.push(t.fg("soft", this.focused ? "tab to type" : "tab to answer"));
1480
+ lines.push(this.attached ? line(`${rail} ${keys.join(" ")}`, label, width) : `${rail} ${keys.join(" ")}`);
1481
+ return lines;
1482
+ }
1483
+ };
1484
+
1485
+ // src/ui/components/header.ts
1486
+ var Header = class extends Block {
1487
+ constructor(t, info) {
1488
+ super();
1489
+ this.t = t;
1490
+ this.info = info;
1491
+ }
1492
+ t;
1493
+ info;
1494
+ set(info) {
1495
+ this.info = info;
1496
+ this.changed();
1497
+ }
1498
+ draw(width) {
1499
+ const { t, info } = this;
1500
+ const where = [info.org, info.task, info.model].filter(Boolean).join(" \xB7 ");
1501
+ return [line(` ${t.bold(t.fg("text", "ashr"))} ${t.fg("accent", "labs")}`, t.fg("soft", where), width), ` ${t.fg("hair", "\u2500".repeat(Math.max(0, width - 1)))}`];
1502
+ }
1503
+ };
1504
+
1505
+ // src/ui/components/input-box.ts
1506
+ import { Editor, truncateToWidth as truncateToWidth2, visibleWidth as visibleWidth2 } from "@earendil-works/pi-tui";
1507
+
1508
+ // src/ui/theme.ts
1509
+ var PALETTES = {
1510
+ dark: { text: "#f3f0e8", soft: "#a8a49a", hair: "#4a473f", accent: "#867afe", ok: "#34c38f", bad: "#e07a5f", warn: "#d4a24c" },
1511
+ light: { text: "#141310", soft: "#57544c", hair: "#a29c90", accent: "#4d41d0", ok: "#0f8f62", bad: "#b3452b", warn: "#8f5f10" }
1512
+ };
1513
+ var rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
1514
+ function to256([r, g, b]) {
1515
+ const step = (v) => v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.round((v - 35) / 40));
1516
+ const level = (i) => i === 0 ? 0 : 55 + i * 40;
1517
+ const [qr, qg, qb] = [step(r), step(g), step(b)];
1518
+ const cube = 16 + 36 * qr + 6 * qg + qb;
1519
+ const avg = Math.round((r + g + b) / 3);
1520
+ const greyIndex = avg > 238 ? 255 : avg < 8 ? 232 : 232 + Math.round((avg - 8) / 10);
1521
+ const greyLevel = 8 + (greyIndex - 232) * 10;
1522
+ const dist = (c) => (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
1523
+ return dist([level(qr), level(qg), level(qb)]) <= dist([greyLevel, greyLevel, greyLevel]) ? cube : greyIndex;
1524
+ }
1525
+ function colorMode(env = process.env) {
1526
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return "none";
1527
+ if (/truecolor|24bit/i.test(env.COLORTERM ?? "")) return "truecolor";
1528
+ return "256";
1529
+ }
1530
+ function detectBackground(env = process.env, color2) {
1531
+ if (env.ASHR_THEME === "light" || env.ASHR_THEME === "dark") return env.ASHR_THEME;
1532
+ if (color2) return (0.2126 * color2.r + 0.7152 * color2.g + 0.0722 * color2.b) / 255 > 0.5 ? "light" : "dark";
1533
+ return "dark";
1534
+ }
1535
+ function createTheme({ background, mode }) {
1536
+ const palette = PALETTES[background];
1537
+ const wrap = (open4, close) => (s) => mode === "none" ? s : `${open4}${s}${close}`;
1538
+ return {
1539
+ background,
1540
+ mode,
1541
+ fg(role, s) {
1542
+ if (mode === "none") return s;
1543
+ const c = rgb(palette[role]);
1544
+ const open4 = mode === "truecolor" ? `\x1B[38;2;${c[0]};${c[1]};${c[2]}m` : `\x1B[38;5;${to256(c)}m`;
1545
+ return `${open4}${s}\x1B[39m`;
1546
+ },
1547
+ bold: wrap("\x1B[1m", "\x1B[22m"),
1548
+ dim: wrap("\x1B[2m", "\x1B[22m"),
1549
+ italic: wrap("\x1B[3m", "\x1B[23m")
1550
+ };
1551
+ }
1552
+ function markdownTheme(t) {
1553
+ const f = (role) => (s) => t.fg(role, s);
1554
+ return {
1555
+ heading: (s) => t.bold(t.fg("text", s)),
1556
+ link: f("accent"),
1557
+ linkUrl: f("soft"),
1558
+ code: f("accent"),
1559
+ codeBlock: f("text"),
1560
+ codeBlockBorder: f("hair"),
1561
+ quote: f("soft"),
1562
+ quoteBorder: f("hair"),
1563
+ hr: f("hair"),
1564
+ listBullet: f("soft"),
1565
+ bold: t.bold,
1566
+ italic: t.italic,
1567
+ strikethrough: (s) => s,
1568
+ underline: (s) => s
1569
+ };
1570
+ }
1571
+ function editorTheme(t) {
1572
+ return {
1573
+ borderColor: (s) => t.fg("hair", s),
1574
+ selectList: {
1575
+ selectedPrefix: (s) => t.fg("accent", s),
1576
+ selectedText: (s) => t.bold(t.fg("accent", s)),
1577
+ description: (s) => t.fg("soft", s),
1578
+ scrollInfo: (s) => t.fg("soft", s),
1579
+ noMatch: (s) => t.fg("soft", s)
1580
+ }
1581
+ };
1582
+ }
1583
+
1584
+ // src/ui/components/input-box.ts
1585
+ var PLACEHOLDER = "Ask, build, or steer the agent\u2026";
1586
+ var InputBox = class {
1587
+ constructor(tui, t) {
1588
+ this.t = t;
1589
+ this.editor = new Editor(tui, editorTheme(t), { paddingX: 1 });
1590
+ }
1591
+ t;
1592
+ editor;
1593
+ get focused() {
1594
+ return this.editor.focused;
1595
+ }
1596
+ set focused(v) {
1597
+ this.editor.focused = v;
1598
+ }
1599
+ set onSubmit(fn) {
1600
+ this.editor.onSubmit = fn;
1601
+ }
1602
+ getText() {
1603
+ return this.editor.getText();
1604
+ }
1605
+ setText(text) {
1606
+ this.editor.setText(text);
1607
+ }
1608
+ handleInput(data) {
1609
+ this.editor.handleInput(data);
1610
+ }
1611
+ invalidate() {
1612
+ this.editor.invalidate();
1613
+ }
1614
+ render(width) {
1615
+ const { t } = this;
1616
+ const inner = Math.max(4, width - 3);
1617
+ const rendered = this.editor.render(inner);
1618
+ const bottom = rendered.findIndex((l, i) => i > 0 && /^[─\s]*$/.test(l.replace(/\x1b\[[0-9;]*m/g, "")) && l.includes("\u2500"));
1619
+ const content = rendered.slice(1, bottom > 0 ? bottom : rendered.length);
1620
+ const below = bottom > 0 ? rendered.slice(bottom + 1) : [];
1621
+ const bar = t.fg("hair", "\u2502");
1622
+ const empty = this.getText() === "";
1623
+ const body = content.map((l, i) => {
1624
+ let text = l;
1625
+ if (empty && i === 0) {
1626
+ const cursor = l.match(/^\s*\x1b\[7m.\x1b\[0m/)?.[0] ?? " ";
1627
+ text = truncateToWidth2(`${cursor}${t.fg("soft", PLACEHOLDER)}`, inner, "\u2026");
1628
+ }
1629
+ const padded = text + " ".repeat(Math.max(0, inner - visibleWidth2(text)));
1630
+ return `${bar}${i === 0 ? t.fg("accent", "\u203A") : " "}${truncateToWidth2(padded, inner, "")}${bar}`;
1631
+ });
1632
+ return [
1633
+ t.fg("hair", `\u250C${"\u2500".repeat(width - 2)}\u2510`),
1634
+ ...body,
1635
+ t.fg("hair", `\u2514${"\u2500".repeat(width - 2)}\u2518`),
1636
+ ...below.map((l) => truncateToWidth2(l, width, ""))
1637
+ ];
1638
+ }
1639
+ };
1640
+
1641
+ // src/ui/components/panels.ts
1642
+ function heading(t, title, sub, width) {
1643
+ return [line(` ${t.bold(t.fg("text", title))} ${t.fg("soft", sub)}`, t.fg("soft", "esc close"), width), ` ${t.fg("hair", "\u2500".repeat(Math.max(0, width - 1)))}`];
1644
+ }
1645
+ var UsagePanel = class extends Block {
1646
+ constructor(t, s) {
1647
+ super();
1648
+ this.t = t;
1649
+ this.s = s;
1650
+ }
1651
+ t;
1652
+ s;
1653
+ draw(width) {
1654
+ const { t, s } = this;
1655
+ const row = (label, value) => ` ${t.fg("soft", label.padEnd(12))}${value}`;
1656
+ const pct = s.contextPercent;
1657
+ const filled = pct === null ? 0 : Math.max(0, Math.min(20, Math.round(pct / 5)));
1658
+ const cached = s.tokens.input ? Math.round(100 * s.tokens.cacheRead / (s.tokens.input + s.tokens.cacheRead)) : 0;
1659
+ return [
1660
+ ...heading(t, "Usage", "this session", width),
1661
+ row("MODEL", t.fg("text", s.model)),
1662
+ row("TOKENS", `${t.fg("text", s.tokens.total.toLocaleString("en-US"))} ${t.fg("soft", `in ${s.tokens.input.toLocaleString("en-US")} \xB7 out ${s.tokens.output.toLocaleString("en-US")} \xB7 ${cached}% cached`)}`),
1663
+ row("CONTEXT", `${t.fg("accent", "\u25AE".repeat(filled))}${t.fg("hair", "\u25AE".repeat(20 - filled))} ${t.fg("text", pct === null ? "\u2014" : `${Math.round(pct)}%`)} ${t.fg("soft", `of ${formatTokens(s.contextWindow)} \xB7 compacts automatically near the limit`)}`),
1664
+ row("MODEL COST", t.fg("text", `$${s.cost.toFixed(2)}`)),
1665
+ row("SESSION", `${t.fg("text", formatDuration(s.elapsedMs))} ${t.fg("soft", `\xB7 ${s.turns} turns \xB7 ${s.toolCalls} tool calls`)}`)
1666
+ ];
1667
+ }
1668
+ };
1669
+ var PermissionsPanel = class extends Block {
1670
+ constructor(t, v) {
1671
+ super();
1672
+ this.t = t;
1673
+ this.v = v;
1674
+ }
1675
+ t;
1676
+ v;
1677
+ draw(width) {
1678
+ const { t, v } = this;
1679
+ const longest = Math.max(10, ...[...v.allow, ...v.deny].map((r) => r.text.length), ...v.ignored.map((r) => r.length));
1680
+ const rows = [
1681
+ ...v.deny.map((r) => ` ${t.fg("bad", "DENY ")} ${t.fg("text", r.text.padEnd(longest))} ${t.fg("soft", r.source)}`),
1682
+ ...v.allow.map((r) => ` ${t.fg("ok", "ALLOW")} ${t.fg("text", r.text.padEnd(longest))} ${t.fg("soft", r.source)}`),
1683
+ ...v.ignored.map((r) => ` ${t.fg("hair", "ALLOW")} ${t.fg("soft", r.padEnd(longest))} ${t.fg("warn", "not trusted \xB7 .ashr/settings.json")}`)
1684
+ ];
1685
+ return [
1686
+ ...heading(t, "Permissions", `mode ${v.mode}`, width),
1687
+ ...rows.length ? rows : [` ${t.fg("soft", "No rules yet. Answer A on an approval to save one.")}`],
1688
+ "",
1689
+ ` ${t.fg("soft", "Rules live in ~/.ashr/settings.json, .ashr/settings.json (team) and .ashr/settings.local.json (you).")}`
1690
+ ];
1691
+ }
1692
+ };
1693
+
1694
+ // src/ui/components/slash-menu.ts
1695
+ var SlashMenu = class extends Block {
1696
+ constructor(t, groups) {
1697
+ super();
1698
+ this.t = t;
1699
+ this.groups = groups;
1700
+ }
1701
+ t;
1702
+ groups;
1703
+ prefix = "/";
1704
+ index = 0;
1705
+ visible() {
1706
+ return this.groups.map((g) => ({ label: g.label, commands: g.commands.filter((c) => c.name.startsWith(this.prefix)) })).filter((g) => g.commands.length > 0);
1707
+ }
1708
+ flat() {
1709
+ return this.visible().flatMap((g) => g.commands);
1710
+ }
1711
+ setGroups(groups) {
1712
+ this.groups = groups;
1713
+ this.changed();
1714
+ }
1715
+ filter(prefix) {
1716
+ this.prefix = prefix || "/";
1717
+ this.index = 0;
1718
+ this.changed();
1719
+ }
1720
+ isEmpty() {
1721
+ return this.flat().length === 0;
1722
+ }
1723
+ selected() {
1724
+ return this.flat()[this.index];
1725
+ }
1726
+ /** dx moves along the list; dy jumps to the first command of the next or previous group. */
1727
+ move(dx, dy) {
1728
+ const flat = this.flat();
1729
+ if (!flat.length) return;
1730
+ if (dx) this.index = (this.index + dx + flat.length) % flat.length;
1731
+ if (dy) {
1732
+ const groups = this.visible();
1733
+ const current = groups.findIndex((g) => g.commands.includes(flat[this.index]));
1734
+ const target = groups[(current + dy + groups.length) % groups.length];
1735
+ this.index = flat.indexOf(target.commands[0]);
1736
+ }
1737
+ this.changed();
1738
+ }
1739
+ draw() {
1740
+ const { t } = this;
1741
+ const sel = this.selected();
1742
+ const rows = this.visible().map((g) => {
1743
+ const items = g.commands.map((c) => {
1744
+ const name = c === sel ? t.bold(t.fg("accent", c.name)) : t.fg("text", c.name);
1745
+ return c.value ? `${name} ${t.fg("soft", c.value)}` : name;
1746
+ });
1747
+ return ` ${t.fg("soft", g.label.padEnd(10))}${items.join(" ")}`;
1748
+ });
1749
+ if (!sel) return [` ${t.fg("soft", "No command starts with that. Enter sends it to the agent.")}`];
1750
+ return [...rows, "", ` ${t.fg("accent", sel.name)} ${t.fg("soft", sel.description)}`];
1751
+ }
1752
+ };
1753
+
1754
+ // src/ui/components/status-line.ts
1755
+ var MODE_COLOUR = { ask: "text", edit: "text", auto: "accent" };
1756
+ var StatusLine = class extends Block {
1757
+ constructor(t) {
1758
+ super();
1759
+ this.t = t;
1760
+ }
1761
+ t;
1762
+ info = { mode: "ask", tokens: 0, cost: 0, contextPercent: null, runs: [], streaming: false };
1763
+ set(info) {
1764
+ this.info = { ...this.info, ...info };
1765
+ this.changed();
1766
+ }
1767
+ get current() {
1768
+ return this.info;
1769
+ }
1770
+ draw(width) {
1771
+ const { t, info } = this;
1772
+ const dot = t.fg("soft", " \xB7 ");
1773
+ const pct = info.contextPercent;
1774
+ const filled = pct === null ? 0 : Math.max(0, Math.min(10, Math.round(pct / 10)));
1775
+ const bar = `${t.fg("accent", "\u25AE".repeat(filled))}${t.fg("hair", "\u25AE".repeat(10 - filled))} ${t.fg("soft", pct === null ? "\u2014" : `${Math.round(pct)}%`)}`;
1776
+ const parts = [t.fg(MODE_COLOUR[info.mode], info.mode), ...info.runs.map((r) => `${t.fg("warn", "\u25D0")} ${t.fg("text", r)}`), bar, t.fg("soft", formatTokens(info.tokens)), t.fg("soft", `$${info.cost.toFixed(2)}`)];
1777
+ const right = info.streaming ? t.fg("soft", "esc stop") : t.fg("soft", info.hint ?? "");
1778
+ return [line(` ${parts.join(dot)}`, right, width)];
1779
+ }
1780
+ };
1781
+
1782
+ // src/ui/components/tool-row.ts
1783
+ var ToolRow = class extends Block {
1784
+ constructor(t, info) {
1785
+ super();
1786
+ this.t = t;
1787
+ this.info = info;
1788
+ }
1789
+ t;
1790
+ info;
1791
+ state = { kind: "running", elapsedMs: 0 };
1792
+ expanded = false;
1793
+ waiting = false;
1794
+ /** While its approval card waits, the row says so instead of counting up. */
1795
+ setWaiting(waiting) {
1796
+ this.waiting = waiting;
1797
+ this.changed();
1798
+ }
1799
+ setRunning(elapsedMs) {
1800
+ this.state = { kind: "running", elapsedMs };
1801
+ this.changed();
1802
+ }
1803
+ finish(r) {
1804
+ const output = r.output ?? r.summary;
1805
+ this.state = r.isError ? { kind: "failed", summary: r.summary, output } : { kind: "done", summary: r.summary, output };
1806
+ this.changed();
1807
+ }
1808
+ toggle() {
1809
+ this.expanded = !this.expanded;
1810
+ this.changed();
1811
+ }
1812
+ get finished() {
1813
+ return this.state.kind !== "running";
1814
+ }
1815
+ draw(width) {
1816
+ const { t, info, state } = this;
1817
+ const left = ` ${t.fg("soft", info.name.padEnd(5))} ${t.fg("text", info.subject.replace(/\s*\n\s*/g, " "))}`;
1818
+ const status = state.kind === "running" ? t.fg("warn", this.waiting ? "needs approval" : `running ${clock(state.elapsedMs)}`) : state.kind === "done" ? t.fg("ok", "done") : t.fg("bad", "failed");
1819
+ const lines = [line(left, status, width)];
1820
+ if (state.kind === "running") return lines;
1821
+ if (this.expanded) {
1822
+ for (const l of state.output.split("\n")) lines.push(` ${t.fg(state.kind === "failed" ? "bad" : "soft", l)}`);
1823
+ } else if (state.kind === "failed" && state.summary) {
1824
+ lines.push(` ${t.fg("bad", state.summary)}`);
1825
+ }
1826
+ return lines;
1827
+ }
1828
+ };
1829
+
1830
+ // src/ui/components/turns.ts
1831
+ import { Markdown } from "@earendil-works/pi-tui";
1832
+ var INDENT = 7;
1833
+ var UserTurn = class extends Block {
1834
+ constructor(t, text) {
1835
+ super();
1836
+ this.t = t;
1837
+ this.text = text;
1838
+ }
1839
+ t;
1840
+ text;
1841
+ draw() {
1842
+ const [first = "", ...rest] = this.text.split("\n");
1843
+ return ["", ` ${this.t.fg("accent", "\u203A")} ${this.t.fg("text", first)}`, ...rest.map((l) => ` ${this.t.fg("text", l)}`)];
1844
+ }
1845
+ };
1846
+ var AssistantTurn = class extends Block {
1847
+ constructor(t) {
1848
+ super();
1849
+ this.t = t;
1850
+ this.md = new Markdown("", 0, 0, markdownTheme(t));
1851
+ }
1852
+ t;
1853
+ text = "";
1854
+ note;
1855
+ md;
1856
+ append(delta) {
1857
+ this.text += delta;
1858
+ this.md.setText(this.text);
1859
+ this.changed();
1860
+ }
1861
+ hasText() {
1862
+ return this.text.trim().length > 0;
1863
+ }
1864
+ /** A soft trailing line, e.g. "stopped by you". */
1865
+ setNote(note) {
1866
+ this.note = note;
1867
+ this.changed();
1868
+ }
1869
+ draw(width) {
1870
+ const { t } = this;
1871
+ const body = this.text ? this.md.render(Math.max(10, width - INDENT)) : [""];
1872
+ const label = ` ${t.bold(t.fg("accent", "Ashr"))} `;
1873
+ const lines = ["", ...body.map((l, i) => (i === 0 ? label : " ".repeat(INDENT)) + l)];
1874
+ if (this.note) lines.push(`${" ".repeat(INDENT)}${t.fg("soft", this.note)}`);
1875
+ return lines;
1876
+ }
1877
+ };
1878
+
1879
+ // src/ui/route.ts
1880
+ function distance(a, b) {
1881
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
1882
+ for (let i = 1; i <= a.length; i++) {
1883
+ let prev = row[0];
1884
+ row[0] = i;
1885
+ for (let j = 1; j <= b.length; j++) {
1886
+ const cur = row[j];
1887
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
1888
+ prev = cur;
1889
+ }
1890
+ }
1891
+ return row[b.length];
1892
+ }
1893
+ function route(input, commands) {
1894
+ const text = input.trim();
1895
+ const first = text.split(/\s/, 1)[0] ?? "";
1896
+ const spec = commands.find((c) => c.name === first);
1897
+ if (spec) return { kind: "command", spec, args: text.slice(first.length).trim() };
1898
+ if (/^\/[a-z-]+$/.test(first)) {
1899
+ const suggestions = commands.map((c) => c.name).filter((n) => distance(n, first) <= 2);
1900
+ if (suggestions.length) return { kind: "unknown", name: first, suggestions };
1901
+ }
1902
+ return { kind: "prompt", text };
1903
+ }
1904
+
1905
+ // src/ui/app.ts
1906
+ var Note = class extends Block {
1907
+ constructor(t, text, role = "soft") {
1908
+ super();
1909
+ this.t = t;
1910
+ this.text = text;
1911
+ this.role = role;
1912
+ }
1913
+ t;
1914
+ text;
1915
+ role;
1916
+ draw() {
1917
+ return this.text.split("\n").map((l) => ` ${this.t.fg(this.role, l)}`);
1918
+ }
1919
+ };
1920
+ var Lines = class extends Block {
1921
+ constructor(lines) {
1922
+ super();
1923
+ this.lines = lines;
1924
+ }
1925
+ lines;
1926
+ draw() {
1927
+ return this.lines;
1928
+ }
1929
+ };
1930
+ var QueueStrip = class extends Block {
1931
+ constructor(t) {
1932
+ super();
1933
+ this.t = t;
1934
+ }
1935
+ t;
1936
+ items = [];
1937
+ set(items) {
1938
+ this.items = items;
1939
+ this.changed();
1940
+ }
1941
+ draw() {
1942
+ const { t } = this;
1943
+ return this.items.map((q, i) => ` ${t.fg("soft", i === 0 ? "QUEUED" : " ")} ${q.kind === "steer" ? t.fg("accent", "steer") : t.fg("soft", "next ")} ${t.fg("text", q.text.replace(/\n/g, " "))}`);
1944
+ }
1945
+ };
1946
+ var TrustPrompt = class extends Block {
1947
+ constructor(t, state) {
1948
+ super();
1949
+ this.t = t;
1950
+ this.state = state;
1951
+ }
1952
+ t;
1953
+ state;
1954
+ draw() {
1955
+ const { t, state } = this;
1956
+ return [
1957
+ ` ${t.fg("text", "This repo has team settings you haven't trusted yet.")}`,
1958
+ "",
1959
+ ...state.allow.map((r) => ` ${t.fg("ok", "ALLOW ")} ${t.fg("text", r)}`),
1960
+ ...state.servers.map((s) => ` ${t.fg("accent", "MCP ")} ${t.fg("text", s.name)} ${t.fg("soft", s.command)}`),
1961
+ "",
1962
+ ` ${t.bold(t.fg("accent", "\u23CE"))} ${t.bold(t.fg("text", "trust and use"))} ${t.fg("accent", "n")} ${t.fg("soft", "continue without")} ${t.fg("accent", "e")} ${t.fg("soft", "show the file")}`
1963
+ ];
1964
+ }
1965
+ };
1966
+ function messageText(content) {
1967
+ if (typeof content === "string") return content;
1968
+ if (!Array.isArray(content)) return "";
1969
+ return content.map((c) => c.type === "text" ? c.text ?? "" : "").join("");
1970
+ }
1971
+ var App = class {
1972
+ constructor(o) {
1973
+ this.o = o;
1974
+ this.t = o.theme ?? createTheme({ background: detectBackground(o.env), mode: colorMode(o.env) });
1975
+ this.tui = new TuiMainScreen(o.terminal);
1976
+ this.queue = new QueueStrip(this.t);
1977
+ this.input = new InputBox(this.tui, this.t);
1978
+ this.status = new StatusLine(this.t);
1979
+ this.menu = new SlashMenu(this.t, menuGroups(COMMANDS, {}));
1980
+ this.org = o.org ?? "default";
1981
+ this.sessionDir = ashrPaths(o.env).sessions(this.org, o.repoRoot);
1982
+ this.trust = repoTrustState(o.repoRoot, o.env);
1983
+ this.settings = loadSettings(o.repoRoot, { trusted: this.trust.trusted, env: o.env });
1984
+ this.mode = this.settings.defaultMode;
1985
+ this.header = new Header(this.t, { org: this.org, model: "" });
1986
+ this.done = new Promise((r) => {
1987
+ this.finish = r;
1988
+ });
1989
+ for (const c of [this.approvalHost, new Spacer(1), this.queue, this.panelHost, this.input, this.menuHost, this.status]) this.live.addChild(c);
1990
+ this.tui.addChild(this.transcript);
1991
+ this.tui.addChild(this.live);
1992
+ }
1993
+ o;
1994
+ t;
1995
+ tui;
1996
+ transcript = new Container();
1997
+ live = new Container();
1998
+ approvalHost = new Container();
1999
+ panelHost = new Container();
2000
+ menuHost = new Container();
2001
+ queue;
2002
+ input;
2003
+ status;
2004
+ menu;
2005
+ header;
2006
+ ashr;
2007
+ unsubscribe = () => {
2008
+ };
2009
+ releaseLock = () => {
2010
+ };
2011
+ settings;
2012
+ trust;
2013
+ mode;
2014
+ extraDirs = [];
2015
+ streaming = false;
2016
+ seq = 0;
2017
+ currentTurn;
2018
+ rows = /* @__PURE__ */ new Map();
2019
+ lastFinished;
2020
+ queued = [];
2021
+ pending;
2022
+ picker;
2023
+ trustPrompt;
2024
+ autoExplained = false;
2025
+ lastCtrlC = 0;
2026
+ lastView;
2027
+ ticker;
2028
+ finish;
2029
+ done;
2030
+ org;
2031
+ sessionDir;
2032
+ // ── lifecycle ────────────────────────────────────────────────────────────
2033
+ async start() {
2034
+ let file = this.o.sessionFile;
2035
+ if (!file && this.o.continueLatest) file = (await latestSession(this.o.repoRoot, this.sessionDir))?.file;
2036
+ await this.open(file, this.o.model);
2037
+ this.input.onSubmit = (text) => void this.submit(text);
2038
+ this.input.editor.onChange = () => this.updateMenu();
2039
+ this.tui.addInputListener((data) => this.onKey(data));
2040
+ if (this.trust.needed && !this.trust.trusted) {
2041
+ this.trustPrompt = new TrustPrompt(this.t, this.trust);
2042
+ this.panelHost.addChild(this.trustPrompt);
2043
+ }
2044
+ this.tui.setFocus(this.input);
2045
+ this.tui.start();
2046
+ await this.matchTerminalBackground();
2047
+ this.ticker = setInterval(() => this.tick(), 1e3);
2048
+ this.refreshStatus();
2049
+ if (this.o.resumePicker) await this.pickResume();
2050
+ this.o.onReady?.({ session: () => this.ashr, app: this });
2051
+ }
2052
+ /** Ask the terminal for its background colour and switch to the light palette on a light terminal. */
2053
+ async matchTerminalBackground() {
2054
+ if (this.o.theme || this.o.env.ASHR_THEME || this.t.mode === "none") return;
2055
+ const rgb2 = await this.tui.queryTerminalBackgroundColor({ timeoutMs: 150 }).catch(() => void 0);
2056
+ if (!rgb2 || detectBackground(this.o.env, rgb2) === this.t.background) return;
2057
+ Object.assign(this.t, createTheme({ background: detectBackground(this.o.env, rgb2), mode: this.t.mode }));
2058
+ this.tui.invalidate();
2059
+ this.tui.requestRender(true);
2060
+ }
2061
+ async open(file, model) {
2062
+ if (file) {
2063
+ const lock = acquireLock(file);
2064
+ if ("heldBy" in lock) throw new Error(`That session is open in another terminal (pid ${lock.heldBy}). Close it there, or start a new session.`);
2065
+ this.releaseLock = lock.release;
2066
+ }
2067
+ this.ashr = await createAshrSession({
2068
+ repoRoot: this.o.repoRoot,
2069
+ sessionDir: this.sessionDir,
2070
+ sessionFile: file,
2071
+ modelRuntime: this.o.modelRuntime,
2072
+ model,
2073
+ env: this.o.env,
2074
+ mode: () => this.mode,
2075
+ rules: () => this.settings.rules,
2076
+ extraDirs: () => this.extraDirs,
2077
+ approver: this,
2078
+ onRuleSaved: (rule) => {
2079
+ this.reloadSettings();
2080
+ const shared = localIsShared(this.o.repoRoot) ? ". Git tracks that file, so the rule is shared with your team and this repo will need trusting again" : "";
2081
+ this.note(`Saved ${rule} to .ashr/settings.local.json${shared}`);
2082
+ },
2083
+ onBeforeWrite: (path) => {
2084
+ const leaf = this.ashr.session.sessionManager.getLeafId();
2085
+ if (leaf) snapshotBefore(this.sessionDir, this.ashr.session.sessionId, leaf, path);
2086
+ }
2087
+ });
2088
+ if (!file) this.releaseLock = (() => {
2089
+ const f = this.ashr.session.sessionFile;
2090
+ if (!f) return () => {
2091
+ };
2092
+ const lock = acquireLock(f);
2093
+ return "release" in lock ? lock.release : () => {
2094
+ };
2095
+ })();
2096
+ this.unsubscribe = this.ashr.subscribe((e) => this.onEvent(e));
2097
+ this.header.set({ org: this.org, model: this.ashr.modelLabel });
2098
+ this.transcript.addChild(this.header);
2099
+ if (file) {
2100
+ this.replay();
2101
+ this.note(`Resumed. ${this.ashr.session.messages.length} messages.`);
2102
+ this.mode = restoredMode(lastRecordedMode(this.ashr.session.sessionManager), this.settings.defaultMode);
2103
+ }
2104
+ if (this.ashr.notice) this.note(this.ashr.notice, "warn");
2105
+ this.updateMenuValues();
2106
+ }
2107
+ async close() {
2108
+ this.unsubscribe();
2109
+ if (this.streaming) await this.ashr.session.abort().catch(() => {
2110
+ });
2111
+ await this.ashr.dispose();
2112
+ this.releaseLock();
2113
+ }
2114
+ async quit() {
2115
+ if (this.ticker) clearInterval(this.ticker);
2116
+ await this.close();
2117
+ this.tui.stop();
2118
+ this.finish(0);
2119
+ }
2120
+ // ── transcript ───────────────────────────────────────────────────────────
2121
+ add(c) {
2122
+ this.transcript.addChild(c);
2123
+ this.tui.requestRender();
2124
+ }
2125
+ note(text, role = "soft") {
2126
+ this.add(new Note(this.t, text, role));
2127
+ }
2128
+ replay() {
2129
+ const results = /* @__PURE__ */ new Map();
2130
+ for (const m of this.ashr.session.messages) {
2131
+ if (m.role === "toolResult" && m.toolCallId) results.set(m.toolCallId, Boolean(m.isError));
2132
+ }
2133
+ for (const m of this.ashr.session.messages) {
2134
+ if (m.role === "user") this.transcript.addChild(new UserTurn(this.t, messageText(m.content)));
2135
+ if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
2136
+ for (const part of m.content) {
2137
+ if (part.type === "text" && part.text?.trim()) {
2138
+ const turn = new AssistantTurn(this.t);
2139
+ turn.append(part.text);
2140
+ this.transcript.addChild(turn);
2141
+ }
2142
+ if (part.type === "toolCall") {
2143
+ const row = new ToolRow(this.t, { seq: ++this.seq, name: part.name ?? "tool", subject: this.subject(part.name ?? "", part.arguments ?? {}) });
2144
+ row.finish({ isError: results.get(part.id ?? "") ?? false, summary: "" });
2145
+ this.transcript.addChild(row);
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+ subject(name, args) {
2151
+ if (name === "bash") return String(args.command ?? "");
2152
+ if (typeof args.path === "string") {
2153
+ const rel = relative5(canonical(this.o.repoRoot), canonical(expandToolPath(args.path, this.o.repoRoot)));
2154
+ return rel.startsWith("..") || isAbsolute3(rel) ? args.path : rel;
2155
+ }
2156
+ return JSON.stringify(args);
2157
+ }
2158
+ // ── agent events ─────────────────────────────────────────────────────────
2159
+ onEvent(e) {
2160
+ switch (e.type) {
2161
+ case "text":
2162
+ if (!this.currentTurn) {
2163
+ this.currentTurn = new AssistantTurn(this.t);
2164
+ this.transcript.addChild(this.currentTurn);
2165
+ }
2166
+ this.currentTurn.append(e.delta);
2167
+ break;
2168
+ case "tool_start": {
2169
+ this.currentTurn = void 0;
2170
+ const row = new ToolRow(this.t, { seq: ++this.seq, name: e.name, subject: this.subject(e.name, e.args) });
2171
+ this.rows.set(e.id, { row, started: Date.now() });
2172
+ this.transcript.addChild(row);
2173
+ break;
2174
+ }
2175
+ case "tool_end": {
2176
+ const r = this.rows.get(e.id);
2177
+ r?.row.finish({ isError: e.isError, summary: e.summary, output: e.output });
2178
+ if (r) this.lastFinished = r.row;
2179
+ this.rows.delete(e.id);
2180
+ this.refreshStatus();
2181
+ break;
2182
+ }
2183
+ case "queue": {
2184
+ const still = /* @__PURE__ */ new Set([...e.steering, ...e.followUp]);
2185
+ for (const q of this.queued) if (!still.has(q.text)) this.transcript.addChild(new UserTurn(this.t, q.text));
2186
+ this.queued = this.queued.filter((q) => still.has(q.text));
2187
+ this.queue.set(this.queued);
2188
+ if (still.size) this.currentTurn = void 0;
2189
+ break;
2190
+ }
2191
+ case "settled":
2192
+ this.streaming = false;
2193
+ this.currentTurn = void 0;
2194
+ this.refreshStatus();
2195
+ break;
2196
+ case "error":
2197
+ this.note(`\u2717 ${e.message}`, "bad");
2198
+ break;
2199
+ case "compaction":
2200
+ this.note(e.phase === "start" ? "Compacting the conversation to free context\u2026" : e.error ? `\u2717 Compaction failed: ${e.error}` : "Compacted. Earlier turns are summarised; the full history stays in the session file.", e.error ? "bad" : "soft");
2201
+ break;
2202
+ case "retry":
2203
+ this.note(`Retrying (${e.attempt}/${e.max}): ${e.error}`, "warn");
2204
+ break;
2205
+ default:
2206
+ break;
2207
+ }
2208
+ this.tui.requestRender();
2209
+ }
2210
+ tick() {
2211
+ const now = Date.now();
2212
+ for (const { row, started } of this.rows.values()) row.setRunning(now - started);
2213
+ if (this.rows.size) this.tui.requestRender();
2214
+ }
2215
+ refreshStatus() {
2216
+ if (!this.ashr) return;
2217
+ const stats = this.ashr.session.getSessionStats();
2218
+ const usage = this.ashr.session.getContextUsage();
2219
+ this.status.set({ mode: this.mode, tokens: stats.tokens.total, cost: stats.cost, contextPercent: usage?.percent ?? null, streaming: this.streaming });
2220
+ this.tui.requestRender();
2221
+ }
2222
+ // ── input ────────────────────────────────────────────────────────────────
2223
+ async submit(raw) {
2224
+ this.input.setText("");
2225
+ this.hideMenu();
2226
+ const routed = route(raw, COMMANDS);
2227
+ if (routed.kind === "unknown") {
2228
+ this.note(`${routed.name} is not a command. Did you mean ${routed.suggestions.join(" or ")}?`, "warn");
2229
+ return;
2230
+ }
2231
+ if (routed.kind === "command") {
2232
+ try {
2233
+ await routed.spec.run(this, routed.args);
2234
+ } catch (err) {
2235
+ this.note(`\u2717 ${err.message}`, "bad");
2236
+ }
2237
+ return;
2238
+ }
2239
+ if (!routed.text) return;
2240
+ this.closePanel();
2241
+ if (this.streaming) {
2242
+ this.queued.push({ kind: "steer", text: routed.text });
2243
+ this.queue.set(this.queued);
2244
+ await this.ashr.session.steer(routed.text);
2245
+ this.tui.requestRender();
2246
+ return;
2247
+ }
2248
+ this.startTurn(routed.text);
2249
+ }
2250
+ startTurn(text) {
2251
+ this.add(new UserTurn(this.t, text));
2252
+ this.currentTurn = void 0;
2253
+ this.streaming = true;
2254
+ this.refreshStatus();
2255
+ const withView = this.lastView ? `${text}
2256
+
2257
+ [What the user was just looking at in the terminal]
2258
+ ${this.lastView}` : text;
2259
+ this.lastView = void 0;
2260
+ this.ashr.session.prompt(withView).catch((err) => {
2261
+ this.streaming = false;
2262
+ this.note(`\u2717 ${err instanceof Error ? err.message : String(err)}`, "bad");
2263
+ this.refreshStatus();
2264
+ });
2265
+ }
2266
+ async abortTurn() {
2267
+ const unsent = this.queued.map((q) => q.text);
2268
+ this.queued = [];
2269
+ this.queue.set([]);
2270
+ this.ashr.session.clearQueue();
2271
+ if (this.pending) this.answer({ approve: false, reason: "The user stopped the turn." });
2272
+ await this.ashr.session.abort().catch(() => {
2273
+ });
2274
+ if (this.currentTurn) this.currentTurn.setNote("stopped by you");
2275
+ else this.note("stopped by you");
2276
+ this.currentTurn = void 0;
2277
+ for (const { row } of this.rows.values()) row.finish({ isError: true, summary: "stopped" });
2278
+ this.rows.clear();
2279
+ this.streaming = false;
2280
+ if (unsent.length) this.input.setText(unsent.join("\n"));
2281
+ this.refreshStatus();
2282
+ }
2283
+ onKey(data) {
2284
+ const consume = { consume: true };
2285
+ if (matchesKey(data, "ctrl+c")) {
2286
+ if (this.streaming) void this.abortTurn();
2287
+ else if (Date.now() - this.lastCtrlC < 2e3) void this.quit();
2288
+ else {
2289
+ this.lastCtrlC = Date.now();
2290
+ this.note("Press Ctrl-C again to quit.");
2291
+ }
2292
+ return consume;
2293
+ }
2294
+ if (this.trustPrompt) {
2295
+ if (matchesKey(data, "enter")) {
2296
+ trustRepo(this.o.repoRoot, this.trust.hash, this.o.env);
2297
+ this.trust = repoTrustState(this.o.repoRoot, this.o.env);
2298
+ this.reloadSettings();
2299
+ this.endTrustPrompt("Trusted. The team's rules and connectors now apply in this repo.");
2300
+ } else if (data === "n") this.endTrustPrompt("Continuing without the team's rules and connectors. They stay ignored until you trust them.");
2301
+ else if (data === "e") this.note(`Team settings: ${resolve6(this.o.repoRoot, ".ashr/settings.json")}`);
2302
+ return consume;
2303
+ }
2304
+ if (this.picker) {
2305
+ this.picker.handleInput(data);
2306
+ this.tui.requestRender();
2307
+ return consume;
2308
+ }
2309
+ if (this.pending) {
2310
+ const { card: card2 } = this.pending;
2311
+ if (matchesKey(data, "tab")) {
2312
+ card2.setFocused(!card2.focused);
2313
+ this.tui.requestRender();
2314
+ return consume;
2315
+ }
2316
+ if (card2.focused) {
2317
+ if (data === "a") this.answer({ approve: true });
2318
+ else if (data === "r") this.answer({ approve: false });
2319
+ else if (data === "A" && card2.req.offer) this.answer({ approve: true, always: true });
2320
+ else if (data === "e") card2.toggle();
2321
+ else if (!matchesKey(data, "escape") && !matchesKey(data, "shift+tab")) return consume;
2322
+ if (!matchesKey(data, "escape") && !matchesKey(data, "shift+tab")) {
2323
+ this.tui.requestRender();
2324
+ return consume;
2325
+ }
2326
+ }
2327
+ }
2328
+ if (matchesKey(data, "ctrl+o")) {
2329
+ this.lastFinished?.toggle();
2330
+ this.tui.requestRender();
2331
+ return consume;
2332
+ }
2333
+ if (this.menuHost.children.length) {
2334
+ if (matchesKey(data, "up")) {
2335
+ this.menu.move(-1, 0);
2336
+ this.tui.requestRender();
2337
+ return consume;
2338
+ }
2339
+ if (matchesKey(data, "down")) {
2340
+ this.menu.move(1, 0);
2341
+ this.tui.requestRender();
2342
+ return consume;
2343
+ }
2344
+ if (matchesKey(data, "tab")) {
2345
+ const sel = this.menu.selected();
2346
+ if (sel) this.input.setText(`${sel.name} `);
2347
+ this.updateMenu();
2348
+ return consume;
2349
+ }
2350
+ if (matchesKey(data, "enter")) {
2351
+ const sel = this.menu.selected();
2352
+ const typed = this.input.getText().trim();
2353
+ if (sel && !COMMANDS.some((c) => c.name === typed)) {
2354
+ void this.submit(sel.name);
2355
+ return consume;
2356
+ }
2357
+ }
2358
+ }
2359
+ if (matchesKey(data, "escape")) {
2360
+ if (this.streaming) {
2361
+ void this.abortTurn();
2362
+ return consume;
2363
+ }
2364
+ if (this.panelHost.children.length) {
2365
+ this.closePanel();
2366
+ return consume;
2367
+ }
2368
+ if (this.menuHost.children.length) {
2369
+ this.input.setText("");
2370
+ this.hideMenu();
2371
+ return consume;
2372
+ }
2373
+ }
2374
+ if (matchesKey(data, "shift+tab")) {
2375
+ this.cycleMode();
2376
+ return consume;
2377
+ }
2378
+ if (matchesKey(data, "alt+enter") && this.streaming) {
2379
+ const text = this.input.getText().trim();
2380
+ if (text) {
2381
+ this.input.setText("");
2382
+ this.queued.push({ kind: "next", text });
2383
+ this.queue.set(this.queued);
2384
+ void this.ashr.session.followUp(text);
2385
+ this.tui.requestRender();
2386
+ }
2387
+ return consume;
2388
+ }
2389
+ return void 0;
2390
+ }
2391
+ // ── slash menu ───────────────────────────────────────────────────────────
2392
+ updateMenuValues() {
2393
+ const short = this.ashr ? (this.ashr.modelLabel.split("/")[1] ?? this.ashr.modelLabel).replace(/^claude-/, "") : "";
2394
+ this.menu.setGroups(menuGroups(COMMANDS, { "/model": short, "/mode": this.mode }));
2395
+ }
2396
+ updateMenu() {
2397
+ const text = this.input.getText();
2398
+ if (/^\/\S*$/.test(text)) {
2399
+ this.menu.filter(text);
2400
+ if (!this.menuHost.children.length) this.menuHost.addChild(this.menu);
2401
+ } else this.hideMenu();
2402
+ this.tui.requestRender();
2403
+ }
2404
+ hideMenu() {
2405
+ this.menuHost.clear();
2406
+ }
2407
+ // ── approvals ────────────────────────────────────────────────────────────
2408
+ ask(req, signal) {
2409
+ return new Promise((resolveAnswer) => {
2410
+ const card2 = new ApprovalCard(this.t, req);
2411
+ this.pending = { card: card2, resolve: resolveAnswer };
2412
+ const row = this.rows.get(req.id);
2413
+ if (row) {
2414
+ row.row.setWaiting(true);
2415
+ card2.attached = true;
2416
+ }
2417
+ this.approvalHost.clear();
2418
+ this.approvalHost.addChild(card2);
2419
+ if (req.kind === "spend") this.o.terminal.write("\x07");
2420
+ signal?.addEventListener("abort", () => {
2421
+ if (this.pending?.card === card2) this.answer({ approve: false });
2422
+ }, { once: true });
2423
+ this.tui.requestRender();
2424
+ });
2425
+ }
2426
+ answer(a) {
2427
+ const p = this.pending;
2428
+ if (!p) return;
2429
+ this.pending = void 0;
2430
+ this.approvalHost.clear();
2431
+ const waiting = this.rows.get(p.card.req.id);
2432
+ if (waiting) {
2433
+ waiting.row.setWaiting(false);
2434
+ waiting.started = Date.now();
2435
+ }
2436
+ const what = `${p.card.req.title} ${p.card.req.subject}`.trim();
2437
+ if (!p.card.attached) this.note(a.approve ? `approved ${what}${a.always ? " \xB7 always" : ""}` : `rejected ${what}`, a.approve ? "soft" : "warn");
2438
+ p.resolve(a);
2439
+ this.tui.requestRender();
2440
+ }
2441
+ // ── panels and pickers ───────────────────────────────────────────────────
2442
+ openPanel(c, summary) {
2443
+ this.panelHost.clear();
2444
+ this.panelHost.addChild(c);
2445
+ this.lastView = summary;
2446
+ this.tui.requestRender();
2447
+ }
2448
+ closePanel() {
2449
+ if (this.picker) this.picker = void 0;
2450
+ this.panelHost.clear();
2451
+ this.tui.requestRender();
2452
+ }
2453
+ endTrustPrompt(message) {
2454
+ this.trustPrompt = void 0;
2455
+ this.panelHost.clear();
2456
+ this.note(message);
2457
+ }
2458
+ openPicker(items, onSelect, footnote) {
2459
+ if (!items.length) {
2460
+ this.note("Nothing to pick from yet.");
2461
+ return;
2462
+ }
2463
+ const list = new SelectList(items, 10, editorTheme(this.t).selectList);
2464
+ list.onSelect = (item) => {
2465
+ this.closePanel();
2466
+ onSelect(item.value);
2467
+ };
2468
+ list.onCancel = () => this.closePanel();
2469
+ this.picker = list;
2470
+ this.panelHost.clear();
2471
+ this.panelHost.addChild(list);
2472
+ if (footnote) this.panelHost.addChild(new Note(this.t, footnote));
2473
+ this.tui.requestRender();
2474
+ }
2475
+ showUsage() {
2476
+ const stats = this.ashr.session.getSessionStats();
2477
+ const usage = this.ashr.session.getContextUsage();
2478
+ const created = this.ashr.session.sessionManager.getHeader()?.timestamp;
2479
+ const elapsedMs = created ? Date.now() - new Date(created).getTime() : 0;
2480
+ const panel = new UsagePanel(this.t, {
2481
+ model: this.ashr.modelLabel,
2482
+ tokens: { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, total: stats.tokens.total },
2483
+ contextPercent: usage?.percent ?? null,
2484
+ contextWindow: usage?.contextWindow ?? 0,
2485
+ cost: stats.cost,
2486
+ turns: stats.userMessages,
2487
+ toolCalls: stats.toolCalls,
2488
+ elapsedMs
2489
+ });
2490
+ this.openPanel(panel, `Usage: ${formatTokens(stats.tokens.total)} tokens, $${stats.cost.toFixed(2)}, context ${usage?.percent ?? "unknown"}%.`);
2491
+ }
2492
+ showPermissions(args) {
2493
+ const remove = /^remove\s+(.+)$/.exec(args.trim());
2494
+ if (remove) {
2495
+ const rule = remove[1].trim();
2496
+ if (removeLocalRule(this.o.repoRoot, rule)) {
2497
+ this.reloadSettings();
2498
+ this.note(`Removed ${rule} from .ashr/settings.local.json`);
2499
+ } else this.note(`${rule} is not in .ashr/settings.local.json. Team and user rules are edited in their own files.`, "warn");
2500
+ return;
2501
+ }
2502
+ const v = { mode: this.mode, allow: this.settings.rules.allow, deny: this.settings.rules.deny, ignored: this.settings.ignored.allow };
2503
+ this.openPanel(new PermissionsPanel(this.t, v), `Permission mode ${this.mode}. Allow: ${v.allow.map((r) => r.text).join(", ") || "none"}. Deny: ${v.deny.map((r) => r.text).join(", ") || "none"}.`);
2504
+ }
2505
+ showHelp() {
2506
+ const { t } = this;
2507
+ const lines = [
2508
+ ` ${t.bold(t.fg("text", "Commands"))}`,
2509
+ ...COMMANDS.map((c) => ` ${t.fg("accent", c.name.padEnd(13))}${t.fg("soft", c.description)}`),
2510
+ "",
2511
+ ` ${t.bold(t.fg("text", "Keys"))}`,
2512
+ ` ${t.fg("accent", "\u23CE".padEnd(13))}${t.fg("soft", "send; while the agent works, steer it")}`,
2513
+ ` ${t.fg("accent", "\u2325\u23CE".padEnd(13))}${t.fg("soft", "queue a message for when the agent finishes")}`,
2514
+ ` ${t.fg("accent", "esc".padEnd(13))}${t.fg("soft", "stop the agent")}`,
2515
+ ` ${t.fg("accent", "\u21E7\u21E5".padEnd(13))}${t.fg("soft", "permission mode: ask \u2192 edit \u2192 auto")}`,
2516
+ ` ${t.fg("accent", "tab".padEnd(13))}${t.fg("soft", "while an approval waits: switch between answering it and typing")}`,
2517
+ ` ${t.fg("accent", "ctrl-o".padEnd(13))}${t.fg("soft", "show or hide the full output of the last tool")}`,
2518
+ ` ${t.fg("accent", "ctrl-c \xD72".padEnd(13))}${t.fg("soft", "quit")}`
2519
+ ];
2520
+ this.openPanel(new Lines(lines));
2521
+ }
2522
+ async pickResume() {
2523
+ const sessions = await listSessions(this.o.repoRoot, this.sessionDir);
2524
+ const current = this.ashr.session.sessionFile;
2525
+ this.openPicker(
2526
+ sessions.filter((s) => s.file !== current).map((s) => ({ value: s.file, label: s.name || "(empty)", description: `${new Date(s.updatedAt).toLocaleString()} \xB7 ${s.messages} messages` })),
2527
+ (file) => void this.switchSession(file)
2528
+ );
2529
+ }
2530
+ userMessages() {
2531
+ return this.ashr.session.getUserMessagesForForking().slice().reverse().map((m) => ({ value: m.entryId, label: m.text.split("\n")[0] ?? "" }));
2532
+ }
2533
+ /** The agent's file changes made at or after `entryId` on the current branch. */
2534
+ changesSince(entryId) {
2535
+ const branch = this.ashr.session.sessionManager.getBranch();
2536
+ const at = branch.findIndex((e) => e.id === entryId);
2537
+ return at < 0 ? [] : snapshotsAfter(this.sessionDir, this.ashr.session.sessionId, branch.slice(at).map((e) => e.id));
2538
+ }
2539
+ offerRestore(snaps) {
2540
+ if (!snaps.length) return;
2541
+ const shown = snaps.map((s) => this.subject("read", { path: s.path }));
2542
+ this.openPicker(
2543
+ [
2544
+ { value: "yes", label: `Restore ${snaps.length} file${snaps.length === 1 ? "" : "s"}`, description: shown.join(", ") },
2545
+ { value: "no", label: "Keep files as they are" }
2546
+ ],
2547
+ (v) => {
2548
+ if (v !== "yes") return;
2549
+ restore(snaps);
2550
+ this.note(`Restored ${shown.join(", ")}`);
2551
+ },
2552
+ "Restores the agent's file edits only. Shell side effects and your own edits are not restored."
2553
+ );
2554
+ }
2555
+ async pickRewind() {
2556
+ this.openPicker(this.userMessages(), (entryId) => void this.rewindTo(entryId), "Go back to before this message in this session.");
2557
+ }
2558
+ async rewindTo(entryId) {
2559
+ const snaps = this.changesSince(entryId);
2560
+ const result = await this.ashr.session.navigateTree(entryId);
2561
+ this.transcript.clear();
2562
+ this.seq = 0;
2563
+ this.transcript.addChild(this.header);
2564
+ this.replay();
2565
+ this.note("Rewound. Edit the message and send it, or ask something else.");
2566
+ this.input.setText(result.editorText ?? "");
2567
+ this.offerRestore(snaps);
2568
+ this.refreshStatus();
2569
+ }
2570
+ async pickFork() {
2571
+ this.openPicker(this.userMessages(), (entryId) => void this.forkAt(entryId), "Start a new session from before this message. This session stays as it is.");
2572
+ }
2573
+ async forkAt(entryId) {
2574
+ const sm = this.ashr.session.sessionManager;
2575
+ const entry = sm.getEntries().find((e) => e.id === entryId);
2576
+ const text = messageText(entry?.message?.content);
2577
+ const snaps = this.changesSince(entryId);
2578
+ const file = entry?.parentId ? sm.createBranchedSession(entry.parentId) : void 0;
2579
+ await this.switchSession(file);
2580
+ this.note("Forked into a new session. The original is unchanged.");
2581
+ this.input.setText(text);
2582
+ this.offerRestore(snaps);
2583
+ }
2584
+ async switchSession(file) {
2585
+ const holder = file ? lockHolder(file) : void 0;
2586
+ if (holder) {
2587
+ this.note(`That session is open in another terminal (pid ${holder}). Close it there, or /fork it to continue here.`, "warn");
2588
+ return;
2589
+ }
2590
+ await this.close();
2591
+ this.transcript.clear();
2592
+ this.seq = 0;
2593
+ try {
2594
+ await this.open(file, void 0);
2595
+ } catch (err) {
2596
+ this.note(`\u2717 ${err.message}`, "bad");
2597
+ await this.open(void 0, void 0);
2598
+ }
2599
+ this.refreshStatus();
2600
+ }
2601
+ async compact() {
2602
+ await this.ashr.session.compact();
2603
+ this.refreshStatus();
2604
+ }
2605
+ async setModel(arg) {
2606
+ const runtime = this.ashr.modelRuntime;
2607
+ if (!arg) {
2608
+ const models = runtime.getModels().filter((m) => hasKey(m.provider, process.env) || m.provider === this.ashr.session.model?.provider);
2609
+ this.openPicker(models.map((m) => ({ value: `${m.provider}/${m.id}`, label: `${m.provider}/${m.id}` })), (v) => void this.setModel(v));
2610
+ return;
2611
+ }
2612
+ try {
2613
+ const { model, label } = pickModel(runtime, { requested: arg, env: process.env });
2614
+ await this.ashr.session.setModel(model);
2615
+ this.ashr.modelLabel = label;
2616
+ this.header.set({ org: this.org, model: label });
2617
+ this.updateMenuValues();
2618
+ this.note(`Model ${label}`);
2619
+ } catch (err) {
2620
+ this.note(`\u2717 ${err.message}`, "bad");
2621
+ }
2622
+ }
2623
+ setMode(arg) {
2624
+ const m = arg.trim();
2625
+ if (!["ask", "edit", "auto"].includes(m)) {
2626
+ this.note(`Mode is ${this.mode}. Use /mode ask, /mode edit or /mode auto.`);
2627
+ return;
2628
+ }
2629
+ this.mode = m;
2630
+ recordMode(this.ashr.session.sessionManager, m);
2631
+ if (m === "auto" && !this.autoExplained) {
2632
+ this.autoExplained = true;
2633
+ this.note("Auto mode: everything inside the repo runs without asking. Spend, outside-repo and dangerous commands still ask.", "warn");
2634
+ }
2635
+ this.updateMenuValues();
2636
+ this.refreshStatus();
2637
+ }
2638
+ cycleMode() {
2639
+ this.setMode(this.mode === "ask" ? "edit" : this.mode === "edit" ? "auto" : "ask");
2640
+ }
2641
+ addDir(path) {
2642
+ if (!path) {
2643
+ this.note("Usage: /add-dir <path>");
2644
+ return;
2645
+ }
2646
+ const abs = resolve6(this.o.repoRoot, path);
2647
+ if (!existsSync6(abs) || !statSync2(abs).isDirectory()) {
2648
+ this.note(`\u2717 ${abs} is not a directory.`, "bad");
2649
+ return;
2650
+ }
2651
+ this.extraDirs.push(abs);
2652
+ this.note(`The agent may use ${abs} for this session.`);
2653
+ }
2654
+ reloadSettings() {
2655
+ this.settings = loadSettings(this.o.repoRoot, { trusted: this.trust.trusted, env: this.o.env });
2656
+ }
2657
+ };
2658
+ async function runApp(o) {
2659
+ const app = new App(o);
2660
+ try {
2661
+ await app.start();
2662
+ } catch (err) {
2663
+ app.tui.stop();
2664
+ throw err;
2665
+ }
2666
+ return app.done;
2667
+ }
2668
+
2669
+ // src/hosted/entry.ts
2670
+ import { spawn } from "node:child_process";
2671
+ import { hostname } from "node:os";
2672
+ import { ProcessTerminal } from "@earendil-works/pi-tui";
2673
+
2674
+ // src/platform/terminal-login.ts
2675
+ import { createServer } from "node:http";
2676
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2677
+ var same = (a, b) => Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
2678
+ async function readForm(request) {
2679
+ let length = 0;
2680
+ const chunks = [];
2681
+ for await (const chunk of request) {
2682
+ length += chunk.length;
2683
+ if (length > 16384) throw Error("Too large");
2684
+ chunks.push(chunk);
2685
+ }
2686
+ return new URLSearchParams(Buffer.concat(chunks).toString());
2687
+ }
2688
+ async function startTerminalLogin(options) {
2689
+ const dashboard = new URL(options.dashboardUrl);
2690
+ if (dashboard.protocol !== "https:" && !(dashboard.protocol === "http:" && ["127.0.0.1", "localhost"].includes(dashboard.hostname)))
2691
+ throw Error("Dashboard must use HTTPS (or explicitly configured local development).");
2692
+ if (dashboard.username || dashboard.password) throw Error("Dashboard URL cannot contain credentials.");
2693
+ if (options.signal?.aborted) throw Error("Sign-in cancelled.");
2694
+ const state = randomBytes(32).toString("hex");
2695
+ let host = "";
2696
+ let settle;
2697
+ const result = new Promise((resolve10, reject) => {
2698
+ settle = { resolve: resolve10, reject };
2699
+ });
2700
+ const page = (response, status, title, detail) => {
2701
+ response.writeHead(status, {
2702
+ "Content-Type": "text/html; charset=utf-8",
2703
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
2704
+ });
2705
+ response.end(resultPage(title, detail));
2706
+ };
2707
+ const server = createServer(async (request, response) => {
2708
+ response.setHeader("Cache-Control", "no-store");
2709
+ response.setHeader("Referrer-Policy", "no-referrer");
2710
+ response.setHeader("X-Content-Type-Options", "nosniff");
2711
+ if (request.headers.host !== host || request.method !== "POST" || request.url !== "/callback" || request.headers.origin !== void 0 && request.headers.origin !== "null" && request.headers.origin !== dashboard.origin) {
2712
+ page(response, 403, "This link doesn't belong to this terminal.", "Run ashr again to start a new sign-in.");
2713
+ return;
2714
+ }
2715
+ let form;
2716
+ try {
2717
+ form = await readForm(request);
2718
+ } catch {
2719
+ page(response, 400, "Sign-in failed.", "Run ashr again to start a new sign-in.");
2720
+ return;
2721
+ }
2722
+ if (!same(form.get("state") ?? "", state)) {
2723
+ page(response, 403, "This link doesn't belong to this terminal.", "Run ashr again to start a new sign-in.");
2724
+ return;
2725
+ }
2726
+ if (form.get("error")) {
2727
+ page(response, 200, "Sign-in cancelled.", "Nothing was shared. You can close this tab.");
2728
+ finish(Error("Sign-in was cancelled in the browser."));
2729
+ return;
2730
+ }
2731
+ const key2 = form.get("key") ?? "";
2732
+ if (!/^ask_[A-Za-z0-9]{8,200}$/.test(key2)) {
2733
+ page(response, 400, "Sign-in failed.", "Manifold didn't return a usable key. Run ashr again.");
2734
+ finish(Error("The dashboard returned no usable key."));
2735
+ return;
2736
+ }
2737
+ page(response, 200, "You're signed in.", "Return to your terminal. You can close this tab.");
2738
+ finish(void 0, { key: key2, email: form.get("email") || void 0, organization: form.get("organization") || void 0 });
2739
+ });
2740
+ server.requestTimeout = 1e4;
2741
+ server.headersTimeout = 1e4;
2742
+ const timer = setTimeout(() => finish(Error("Sign-in timed out. Run ashr again.")), 6e5);
2743
+ const abort = () => finish(Error("Sign-in cancelled."));
2744
+ function finish(error, login2) {
2745
+ if (!settle) return;
2746
+ const { resolve: resolve10, reject } = settle;
2747
+ settle = void 0;
2748
+ clearTimeout(timer);
2749
+ options.signal?.removeEventListener("abort", abort);
2750
+ setTimeout(() => {
2751
+ server.close();
2752
+ server.closeAllConnections();
2753
+ }, 250).unref();
2754
+ if (error) reject(error);
2755
+ else resolve10(login2);
2756
+ }
2757
+ await new Promise((resolve10, reject) => {
2758
+ server.once("error", reject);
2759
+ server.listen(0, "127.0.0.1", resolve10);
2760
+ });
2761
+ const address = server.address();
2762
+ if (!address || typeof address === "string") {
2763
+ finish(Error("Loopback listener failed."));
2764
+ return result;
2765
+ }
2766
+ host = `127.0.0.1:${address.port}`;
2767
+ options.signal?.addEventListener("abort", abort, { once: true });
2768
+ const url = new URL("/terminal", dashboard);
2769
+ url.search = new URLSearchParams({ port: String(address.port), state, device: options.device }).toString();
2770
+ options.onUrl(url.toString());
2771
+ return result;
2772
+ }
2773
+ function resultPage(title, detail) {
2774
+ const escape = (text) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
2775
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} \xB7 Ashr</title><style>
2776
+ :root{--paper:#faf8f4;--raised:#fffdf9;--ink:#141310;--soft:#57544c;--line:#dcd8ce;--accent:#4d41d0}
2777
+ @media (prefers-color-scheme:dark){:root{--paper:#141310;--raised:#1c1b17;--ink:#f4f1e9;--soft:#a9a49a;--line:#34322c;--accent:#a49bff}}
2778
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:3rem 1.25rem;background:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px),var(--paper);background-size:2.5rem 2.5rem;color:var(--ink);font:15px/1.55 "Helvetica Neue",system-ui,sans-serif}
2779
+ main{width:min(28rem,100%);padding:2.5rem;background:var(--raised);border:1px solid var(--line);box-shadow:0 18px 40px -18px rgba(20,19,16,.25)}
2780
+ p.e{margin:0 0 .8rem;color:var(--accent);font:500 .68rem ui-monospace,monospace;letter-spacing:.18em}
2781
+ h1{margin:0 0 .6rem;font-size:2rem;font-weight:650;letter-spacing:-.035em;line-height:1.05}p{margin:0;color:var(--soft)}
2782
+ </style></head><body><main><p class="e">ASHR TERMINAL</p><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
2783
+ }
2784
+
2785
+ // src/hosted/client.ts
2786
+ var ManifoldHttpError = class extends Error {
2787
+ constructor(status, message) {
2788
+ super(`HTTP ${status}: ${message}`);
2789
+ this.status = status;
2790
+ }
2791
+ status;
2792
+ };
2793
+ var ManifoldClient = class {
2794
+ constructor(options) {
2795
+ this.options = options;
2796
+ this.base = new URL(options.apiUrl);
2797
+ if (!["http:", "https:"].includes(this.base.protocol) || this.base.username || this.base.password || this.base.search || this.base.hash || this.base.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(this.base.hostname))
2798
+ throw new Error(
2799
+ "Use HTTPS or a loopback HTTP API URL without credentials"
2800
+ );
2801
+ }
2802
+ options;
2803
+ base;
2804
+ get apiOrigin() {
2805
+ return this.base.origin;
2806
+ }
2807
+ async request(path, init = {}) {
2808
+ const response = await this.requestResponse(path, init, "application/json");
2809
+ if (response.status === 204) return void 0;
2810
+ return await response.json();
2811
+ }
2812
+ async requestStream(path, init = {}) {
2813
+ return this.requestResponse(path, init, "text/event-stream");
2814
+ }
2815
+ async requestResponse(path, init, accept) {
2816
+ const decoded = decodeURIComponent(path.split("?")[0]);
2817
+ if (!decoded.startsWith("/v1/") || decoded.includes("\\") || decoded.split("/").some((p) => p === ".." || p === ".") || /[\x00-\x1f#]/.test(decoded) || /[\x00-\x20#]/.test(path.split("?")[0]) || /%2f|%5c/i.test(path.split("?")[0]))
2818
+ throw new Error("Invalid API path");
2819
+ const url = new URL(path, this.base);
2820
+ if (url.origin !== this.base.origin || !url.pathname.startsWith("/v1/"))
2821
+ throw new Error("Invalid API path");
2822
+ init.signal?.throwIfAborted();
2823
+ const signal = init.signal;
2824
+ const token = signal ? await new Promise((resolve10, reject) => {
2825
+ const cancel = () => {
2826
+ signal.removeEventListener("abort", cancel);
2827
+ reject(
2828
+ signal.reason ?? new DOMException("Local request paused", "AbortError")
2829
+ );
2830
+ };
2831
+ signal.addEventListener("abort", cancel, { once: true });
2832
+ void Promise.resolve().then(() => this.options.getToken()).then(
2833
+ (value) => {
2834
+ signal.removeEventListener("abort", cancel);
2835
+ resolve10(value);
2836
+ },
2837
+ (error) => {
2838
+ signal.removeEventListener("abort", cancel);
2839
+ reject(error);
2840
+ }
2841
+ );
2842
+ }) : await this.options.getToken();
2843
+ init.signal?.throwIfAborted();
2844
+ if (!token) throw new ManifoldHttpError(401, "Authentication required");
2845
+ const headers = new Headers(init.headers);
2846
+ if (!headers.has("authorization"))
2847
+ headers.set("authorization", `Bearer ${token}`);
2848
+ headers.set("accept", accept);
2849
+ if (init.body && !headers.has("content-type"))
2850
+ headers.set("content-type", "application/json");
2851
+ let response;
2852
+ try {
2853
+ response = await (this.options.fetch ?? fetch)(url, {
2854
+ ...init,
2855
+ headers,
2856
+ redirect: "manual"
2857
+ });
2858
+ } catch {
2859
+ if (init.signal?.aborted)
2860
+ throw init.signal.reason ?? new DOMException("Local request paused", "AbortError");
2861
+ throw new Error(
2862
+ "Connection failed. The request may have been accepted; retry with the same request ID."
2863
+ );
2864
+ }
2865
+ if (response.status >= 300 && response.status < 400)
2866
+ throw new Error("API redirect refused");
2867
+ if (!response.ok) {
2868
+ let detail = "Request failed";
2869
+ try {
2870
+ const body = await response.json();
2871
+ detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail ?? detail);
2872
+ } catch {
2873
+ }
2874
+ throw new ManifoldHttpError(
2875
+ response.status,
2876
+ [token, headers.get("authorization")?.replace(/^Bearer /, "") ?? ""].filter(Boolean).reduce(
2877
+ (value, secret) => value.split(secret).join("[credential]"),
2878
+ detail
2879
+ )
2880
+ );
2881
+ }
2882
+ return response;
2883
+ }
2884
+ };
2885
+
2886
+ // src/hosted/app.ts
2887
+ import {
2888
+ TuiMainScreen as TuiMainScreen2,
2889
+ matchesKey as matchesKey2,
2890
+ wrapTextWithAnsi as wrapTextWithAnsi2
2891
+ } from "@earendil-works/pi-tui";
2892
+ import { parse as parse3 } from "shell-quote";
2893
+
2894
+ // src/hosted/workspace.ts
2895
+ import { randomUUID as randomUUID2 } from "node:crypto";
2896
+ import { lstat, readFile, readdir, open } from "node:fs/promises";
2897
+ import { basename as basename3, join as join8, resolve as resolve7 } from "node:path";
2898
+ function terminalText(value) {
2899
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value);
2900
+ return text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
2901
+ }
2902
+ function safeWorkspacePath(path) {
2903
+ if (!path || path.length > 300 || path.includes("\\") || path.startsWith("/") || path.split("/").some((p) => !p || p === "." || p === ".." || p.startsWith(".")) || /[\x00-\x1f]/.test(path))
2904
+ throw Error("Use a safe relative skill path");
2905
+ return path;
2906
+ }
2907
+ async function writeExclusive(destination, content) {
2908
+ const file = await open(resolve7(destination), "wx", 384);
2909
+ try {
2910
+ await file.writeFile(content);
2911
+ } finally {
2912
+ await file.close();
2913
+ }
2914
+ }
2915
+ async function importSkill(path) {
2916
+ const root2 = resolve7(path);
2917
+ const stat2 = await lstat(root2);
2918
+ if (stat2.isSymbolicLink())
2919
+ throw Error("Skill paths cannot be symbolic links");
2920
+ const files = {};
2921
+ let bytes = 0;
2922
+ async function read2(file, name2) {
2923
+ safeWorkspacePath(name2);
2924
+ const info = await lstat(file);
2925
+ if (info.isSymbolicLink() || !info.isFile())
2926
+ throw Error("Skill paths must be regular files");
2927
+ if (info.size > 1e6) throw Error("Skill exceeds 1 MB");
2928
+ const value = await readFile(file, "utf8");
2929
+ bytes += Buffer.byteLength(value);
2930
+ if (bytes > 1e6 || Object.keys(files).length >= 100)
2931
+ throw Error("Import at most 100 files totaling 1 MB");
2932
+ files[name2] = value;
2933
+ }
2934
+ async function walk(dir, prefix = "") {
2935
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
2936
+ const name2 = prefix + entry.name;
2937
+ safeWorkspacePath(name2);
2938
+ if (entry.isSymbolicLink())
2939
+ throw Error("Skill paths cannot be symbolic links");
2940
+ if (entry.isDirectory()) await walk(join8(dir, entry.name), name2 + "/");
2941
+ else await read2(join8(dir, entry.name), name2);
2942
+ }
2943
+ }
2944
+ if (stat2.isDirectory()) await walk(root2);
2945
+ else {
2946
+ if (basename3(root2) !== "SKILL.md")
2947
+ throw Error("Import a SKILL.md file or skill folder");
2948
+ await read2(root2, "SKILL.md");
2949
+ }
2950
+ const source = files["SKILL.md"] ?? "";
2951
+ const name = /^name:\s*([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/m.exec(source)?.[1];
2952
+ if (!source.startsWith("---\n") && !source.startsWith("---\r\n") || !name || name.length > 64 || !/^description:\s*\S/m.test(source))
2953
+ throw Error("SKILL.md needs name and description frontmatter");
2954
+ return { name, files, enabled: true };
2955
+ }
2956
+ function normalizeHostedTask(value) {
2957
+ const canonical2 = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2958
+ if (!/^[a-z0-9_]+$/.test(canonical2) || canonical2.length > 200)
2959
+ throw Error("Enter a valid task name");
2960
+ return canonical2;
2961
+ }
2962
+ var HostedWorkspace = class {
2963
+ constructor(client, task2) {
2964
+ this.client = client;
2965
+ this.scope = task2 === void 0 ? void 0 : normalizeHostedTask(task2);
2966
+ }
2967
+ client;
2968
+ detail;
2969
+ events = [];
2970
+ cursor = 0;
2971
+ connection = "Disconnected";
2972
+ generation = 0;
2973
+ pending = /* @__PURE__ */ new Map();
2974
+ scope;
2975
+ pollInFlight;
2976
+ get task() {
2977
+ return this.scope;
2978
+ }
2979
+ get chat() {
2980
+ return this.detail?.id;
2981
+ }
2982
+ get busy() {
2983
+ return ["working", "queued", "stopping"].includes(
2984
+ this.detail?.status ?? ""
2985
+ );
2986
+ }
2987
+ path(id = this.chat) {
2988
+ if (!id) throw Error("Open or create a hosted chat first");
2989
+ return `/v1/agent/chats/${encodeURIComponent(id)}`;
2990
+ }
2991
+ async list(search = "") {
2992
+ const result = await this.client.request(
2993
+ `/v1/agent/chats${this.scope ? "?task=" + encodeURIComponent(this.scope) : ""}`
2994
+ );
2995
+ return result.chats.filter(
2996
+ (c) => c.name.toLowerCase().includes(search.toLowerCase()) || c.id.includes(search)
2997
+ );
2998
+ }
2999
+ setTask(task2) {
3000
+ this.scope = task2 === void 0 ? void 0 : normalizeHostedTask(task2);
3001
+ this.generation++;
3002
+ this.detail = void 0;
3003
+ this.events = [];
3004
+ this.cursor = 0;
3005
+ this.connection = "Disconnected";
3006
+ }
3007
+ async open(id) {
3008
+ const gen = ++this.generation;
3009
+ this.detail = void 0;
3010
+ this.events = [];
3011
+ this.cursor = 0;
3012
+ this.connection = "Connecting";
3013
+ try {
3014
+ const detail = await this.client.request(this.path(id));
3015
+ if (gen !== this.generation) return;
3016
+ if (this.scope && detail.task !== this.scope)
3017
+ throw Error("Chat belongs to a different task");
3018
+ this.detail = detail;
3019
+ } catch (error) {
3020
+ if (gen === this.generation) this.connection = "Open failed";
3021
+ throw error;
3022
+ }
3023
+ }
3024
+ async create(name = "New chat") {
3025
+ const gen = this.generation;
3026
+ const chat = await this.client.request("/v1/agent/chats", {
3027
+ method: "POST",
3028
+ body: JSON.stringify({ name, task: this.scope ?? null })
3029
+ });
3030
+ if (gen === this.generation) await this.open(chat.id);
3031
+ return chat;
3032
+ }
3033
+ async refresh() {
3034
+ const gen = this.generation, id = this.chat;
3035
+ if (!id) return;
3036
+ const detail = await this.client.request(this.path(id));
3037
+ if (gen === this.generation) this.detail = detail;
3038
+ }
3039
+ async rename(name) {
3040
+ if (!name.trim() || name.length > 120)
3041
+ throw Error("Name must be 1\u2013120 characters");
3042
+ const gen = this.generation;
3043
+ const updated = await this.client.request(this.path(), {
3044
+ method: "PATCH",
3045
+ body: JSON.stringify({ name })
3046
+ });
3047
+ if (gen === this.generation && this.detail)
3048
+ this.detail = { ...this.detail, ...updated };
3049
+ }
3050
+ async fork(name, through) {
3051
+ const gen = this.generation;
3052
+ const chat = await this.client.request(this.path() + "/fork", {
3053
+ method: "POST",
3054
+ body: JSON.stringify({
3055
+ name,
3056
+ ...through === void 0 ? {} : { through }
3057
+ })
3058
+ });
3059
+ if (gen === this.generation) await this.open(chat.id);
3060
+ return chat;
3061
+ }
3062
+ poll() {
3063
+ if (this.pollInFlight) return this.pollInFlight;
3064
+ const pending = this.pollPage();
3065
+ this.pollInFlight = pending;
3066
+ void pending.finally(() => {
3067
+ if (this.pollInFlight === pending) this.pollInFlight = void 0;
3068
+ }).catch(() => {
3069
+ });
3070
+ return pending;
3071
+ }
3072
+ async pollPage() {
3073
+ if (!this.chat) return;
3074
+ const gen = this.generation, id = this.chat;
3075
+ try {
3076
+ const page = await this.client.request(
3077
+ this.path(id) + `/events?after=${this.cursor}&limit=500`
3078
+ );
3079
+ if (gen !== this.generation) return;
3080
+ const seen = new Set(this.events.map((e) => e.seq));
3081
+ for (const e of page.events) {
3082
+ if (!Number.isSafeInteger(e.seq) || e.seq <= 0)
3083
+ throw Error("Invalid event cursor");
3084
+ if (!seen.has(e.seq)) {
3085
+ this.events.push(e);
3086
+ seen.add(e.seq);
3087
+ if (e.kind === "status" && this.detail)
3088
+ this.detail.status = String(e.data.status);
3089
+ }
3090
+ }
3091
+ this.events.sort((a, b) => a.seq - b.seq);
3092
+ if (!Number.isSafeInteger(page.cursor) || page.cursor < this.cursor)
3093
+ throw Error("Invalid event cursor");
3094
+ this.cursor = page.cursor;
3095
+ this.connection = "Connected";
3096
+ } catch (error) {
3097
+ if (gen === this.generation)
3098
+ this.connection = error instanceof ManifoldHttpError && [401, 403].includes(error.status) ? "Authentication required" : "Disconnected";
3099
+ throw error;
3100
+ }
3101
+ }
3102
+ async send(kind, text = "", options = {}) {
3103
+ if (["prompt", "steer", "follow_up", "shell"].includes(kind) && !text.trim())
3104
+ throw Error("Enter a message or command");
3105
+ if (text.length > 1e5) throw Error("Command exceeds 100000 characters");
3106
+ const chat = this.chat;
3107
+ this.path(chat);
3108
+ const old = [...this.pending.values()].find(
3109
+ (p) => p.chat === chat && p.body.kind === kind && p.body.text === text && JSON.stringify(p.body.options) === JSON.stringify(options)
3110
+ );
3111
+ const attempt = old ?? {
3112
+ chat,
3113
+ body: { request_id: randomUUID2(), kind, text, options }
3114
+ };
3115
+ this.pending.set(attempt.body.request_id, attempt);
3116
+ return this.deliver(attempt);
3117
+ }
3118
+ async deliver(attempt) {
3119
+ const result = await this.client.request(
3120
+ this.path(attempt.chat) + "/commands",
3121
+ { method: "POST", body: JSON.stringify(attempt.body) }
3122
+ );
3123
+ this.pending.delete(attempt.body.request_id);
3124
+ if (this.detail?.id === attempt.chat) {
3125
+ if (attempt.body.kind === "stop") this.detail.status = "stopping";
3126
+ else if (!["cancel", "approve", "reject"].includes(attempt.body.kind) && ["idle", "failed", "stopped"].includes(this.detail.status))
3127
+ this.detail.status = "queued";
3128
+ }
3129
+ return result;
3130
+ }
3131
+ async retry(id) {
3132
+ const attempts = [...this.pending.values()].filter(
3133
+ (p) => p.chat === this.chat
3134
+ );
3135
+ const attempt = id ? this.pending.get(id) : attempts.at(-1);
3136
+ if (!attempt || attempt.chat !== this.chat)
3137
+ throw Error("No uncertain command in this chat");
3138
+ return this.deliver(attempt);
3139
+ }
3140
+ async editQueue(id, text) {
3141
+ const gen = this.generation;
3142
+ await this.refresh();
3143
+ if (gen !== this.generation) throw Error("Chat changed during queue edit");
3144
+ const item = this.detail?.commands.find((c) => c.id === id);
3145
+ if (!item || !["accepted", "held"].includes(item.status))
3146
+ throw Error("That command has already been delivered");
3147
+ if (!text.trim()) throw Error("Enter replacement text");
3148
+ await this.send("cancel", id);
3149
+ if (gen !== this.generation)
3150
+ throw Error(
3151
+ "Chat changed after cancellation; original cancelled, replacement not sent"
3152
+ );
3153
+ await this.send(item.kind, text, item.options);
3154
+ await this.refresh();
3155
+ }
3156
+ async skill(skill) {
3157
+ const gen = this.generation, id = this.chat;
3158
+ await this.client.request(this.path(id) + "/skills", {
3159
+ method: "POST",
3160
+ body: JSON.stringify({
3161
+ name: skill.name,
3162
+ files: skill.files,
3163
+ enabled: skill.enabled
3164
+ })
3165
+ });
3166
+ if (gen === this.generation) {
3167
+ await this.refresh();
3168
+ await this.send("reload");
3169
+ }
3170
+ }
3171
+ async export() {
3172
+ const gen = this.generation;
3173
+ await this.pollInFlight;
3174
+ if (gen !== this.generation) throw Error("Chat changed during export");
3175
+ await this.refresh();
3176
+ do {
3177
+ const before = this.events.length;
3178
+ await this.poll();
3179
+ if (gen !== this.generation) throw Error("Chat changed during export");
3180
+ if (this.events.length - before < 500) break;
3181
+ } while (true);
3182
+ if (gen !== this.generation) throw Error("Chat changed during export");
3183
+ return {
3184
+ format: "ashr-chat-v1",
3185
+ name: this.detail?.name,
3186
+ files: this.detail?.files,
3187
+ skills: this.detail?.skills,
3188
+ events: this.events.filter((e) => e.kind !== "delta")
3189
+ };
3190
+ }
3191
+ };
3192
+
3193
+ // src/hosted/render.ts
3194
+ var str = (v) => typeof v === "string" && v ? v : void 0;
3195
+ function subagentEvent(data, subagents) {
3196
+ const id = str(data.subagent_id);
3197
+ const name = str(data.name);
3198
+ const status = str(data.status);
3199
+ if (!id || !name || !status) return void 0;
3200
+ const prev = subagents.get(id);
3201
+ const cap = typeof data.cap_usd === "number" && Number.isFinite(data.cap_usd) ? data.cap_usd : prev?.cap_usd;
3202
+ const model = str(data.model) ?? prev?.model;
3203
+ subagents.set(id, { name, status, ...cap === void 0 ? {} : { cap_usd: cap }, ...model ? { model } : {} });
3204
+ if (status === "running" && !prev) return `subagent ${name} started${model ? ` on ${model}` : ""}${cap === void 0 ? "" : `, cap $${cap.toFixed(2)}`}`;
3205
+ return prev?.status === status ? void 0 : `subagent ${name} ${status}`;
3206
+ }
3207
+ function renderHostedEvent(lines, event, streams, subagents = /* @__PURE__ */ new Map()) {
3208
+ const data = event.data;
3209
+ if (event.kind === "subagent") {
3210
+ const line2 = subagentEvent(data, subagents);
3211
+ if (line2) lines.push(`[${event.seq}] ${terminalText(line2)}`, "");
3212
+ return;
3213
+ }
3214
+ if (event.kind === "tool_update") return;
3215
+ if (event.kind === "delta" || event.kind === "assistant") {
3216
+ if (!data.text) return;
3217
+ const id = String(data.message_id ?? "current");
3218
+ let index = streams.get(id);
3219
+ if (index === void 0) {
3220
+ index = lines.length;
3221
+ lines.push("", "");
3222
+ streams.set(id, index);
3223
+ }
3224
+ lines[index] = `[${event.seq}] assistant${event.kind === "delta" ? " \xB7 streaming" : ""}`;
3225
+ lines[index + 1] = event.kind === "delta" ? (lines[index + 1] ?? "") + terminalText(data.text ?? "") : terminalText(data.text ?? "");
3226
+ if (event.kind === "assistant") streams.delete(id);
3227
+ return;
3228
+ }
3229
+ const sid = str(data.subagent_id);
3230
+ const who = sid ? subagents.get(sid)?.name ?? sid : void 0;
3231
+ lines.push(
3232
+ `[${event.seq}] ${who ? terminalText(who) + " \u25B8 " : ""}${terminalText(event.kind)}${data.tool ? " " + terminalText(data.tool) : ""}`,
3233
+ terminalText(data.text ?? data)
3234
+ );
3235
+ }
3236
+
3237
+ // src/hosted/app.ts
3238
+ var hostedHelpLines = [
3239
+ "HOSTED execution \u2014 same chats, files, queues and approvals as the browser.",
3240
+ "/tasks | /task NAME | /task create NAME | /task all",
3241
+ "/chats [SEARCH] | /open ID | /new NAME | /rename NAME | /fork NAME [SEQ]",
3242
+ "/model PROVIDER/MODEL | /thinking LEVEL | /compact | /reload",
3243
+ "/steer TEXT | /next TEXT | /stop | /approve ID | /reject ID",
3244
+ "/queue | /queue edit ID TEXT | /queue remove ID | /retry [REQUEST_ID] | /pending",
3245
+ "/files | /view PATH | /diff PATH | /download PATH LOCAL_DESTINATION",
3246
+ "/skills | /skill import LOCAL_PATH | /skill view NAME [PATH] | /skill toggle NAME | /skill invoke NAME [TEXT] | /skill reload",
3247
+ "/memory [QUERY] | /memory page OFFSET [QUERY] | /memory delete ID",
3248
+ "/history [FIRST_SEQUENCE] | PageUp/PageDown scroll output | /export LOCAL_DESTINATION",
3249
+ "!COMMAND runs in hosted workspace; !!COMMAND excludes shell output from model context.",
3250
+ "Escape pauses local dashboard transfers or stops active hosted work. Ctrl-C stops active work, otherwise disconnects. /quit disconnects without stopping jobs.",
3251
+ "/help | /quit"
3252
+ ];
3253
+ var HostedScreen = class {
3254
+ constructor(terminal, header) {
3255
+ this.terminal = terminal;
3256
+ this.header = header;
3257
+ }
3258
+ terminal;
3259
+ header;
3260
+ offset = 0;
3261
+ lines = [];
3262
+ invalidate() {
3263
+ }
3264
+ render(width) {
3265
+ const lines = this.lines.flatMap(
3266
+ (l) => wrapTextWithAnsi2(terminalText(l), Math.max(width, 1))
3267
+ );
3268
+ const count = Math.max(3, this.terminal.rows - 9);
3269
+ const end = Math.max(0, lines.length - this.offset);
3270
+ return [
3271
+ terminalText(this.header()),
3272
+ "",
3273
+ ...lines.slice(Math.max(0, end - count), end),
3274
+ this.offset ? "[Viewing history \u2014 PageDown returns to live output]" : ""
3275
+ ];
3276
+ }
3277
+ };
3278
+ async function runHostedApp(options) {
3279
+ const workspace = new HostedWorkspace(options.client, options.task);
3280
+ const env = options.env ?? process.env;
3281
+ const tui = new TuiMainScreen2(options.terminal);
3282
+ const input = new InputBox(
3283
+ tui,
3284
+ createTheme({ background: detectBackground(env), mode: colorMode(env) })
3285
+ );
3286
+ const screen = new HostedScreen(
3287
+ options.terminal,
3288
+ () => `HOSTED \xB7 ${workspace.task ?? "All tasks"} \xB7 ${workspace.detail?.name ?? "No hosted chat"} ${workspace.chat ?? ""} \xB7 ${workspace.detail?.status ?? "idle"} \xB7 ${workspace.connection}`
3289
+ );
3290
+ let done = false, finish = () => {
3291
+ }, timer, failures = 0, seenGeneration = -1, seenCount = 0;
3292
+ const result = new Promise((resolve10) => finish = resolve10);
3293
+ const streams = /* @__PURE__ */ new Map();
3294
+ const subagents = /* @__PURE__ */ new Map();
3295
+ let dashboardAbort;
3296
+ let uncertainComposer;
3297
+ function show(title, value) {
3298
+ screen.lines.push(`${title}:`, terminalText(value));
3299
+ tui.requestRender();
3300
+ }
3301
+ function redraw() {
3302
+ if (seenGeneration !== workspace.generation) {
3303
+ seenGeneration = workspace.generation;
3304
+ seenCount = 0;
3305
+ screen.lines = [];
3306
+ screen.offset = 0;
3307
+ streams.clear();
3308
+ subagents.clear();
3309
+ }
3310
+ for (const event of workspace.events.slice(seenCount))
3311
+ renderHostedEvent(screen.lines, event, streams, subagents);
3312
+ seenCount = workspace.events.length;
3313
+ tui.requestRender();
3314
+ }
3315
+ function quit() {
3316
+ dashboardAbort?.abort();
3317
+ if (done) return;
3318
+ done = true;
3319
+ if (timer) clearTimeout(timer);
3320
+ workspace.setTask(workspace.task);
3321
+ tui.stop();
3322
+ finish(0);
3323
+ }
3324
+ async function poll() {
3325
+ if (done) return;
3326
+ try {
3327
+ await workspace.poll();
3328
+ if (workspace.chat) await workspace.refresh();
3329
+ failures = 0;
3330
+ redraw();
3331
+ } catch (e) {
3332
+ failures++;
3333
+ if (failures === 1 || failures === 5)
3334
+ show("Connection", e instanceof Error ? e.message : String(e));
3335
+ if (e instanceof ManifoldHttpError && [401, 403].includes(e.status)) {
3336
+ failures = 5;
3337
+ }
3338
+ }
3339
+ if (!done && failures < 5)
3340
+ timer = setTimeout(
3341
+ () => void poll(),
3342
+ Math.min(1e4, 500 * 2 ** failures)
3343
+ );
3344
+ else if (!done)
3345
+ show(
3346
+ "Connection",
3347
+ "Polling paused after 5 failures. /reconnect resumes; uncertain commands remain under /pending."
3348
+ );
3349
+ }
3350
+ function reconnect() {
3351
+ failures = 0;
3352
+ if (timer) clearTimeout(timer);
3353
+ void poll();
3354
+ }
3355
+ function requireArg(value, message) {
3356
+ if (!value?.trim()) throw Error(message);
3357
+ return value;
3358
+ }
3359
+ async function execute(raw) {
3360
+ const text = raw.trim();
3361
+ if (!text) return;
3362
+ if (text === "/quit" || text === "/exit") {
3363
+ quit();
3364
+ return;
3365
+ }
3366
+ if (!text.startsWith("/")) {
3367
+ const shell = text.startsWith("!");
3368
+ const kind = shell ? "shell" : workspace.busy ? "steer" : "prompt";
3369
+ const content = shell ? text.replace(/^!!?/, "").trim() : text;
3370
+ const commandOptions = shell ? { excludeFromContext: text.startsWith("!!") } : {};
3371
+ const prior = uncertainComposer;
3372
+ if (prior && prior.raw === text && prior.chat === workspace.chat && workspace.pending.has(prior.request_id)) {
3373
+ await workspace.retry(prior.request_id);
3374
+ } else {
3375
+ try {
3376
+ await workspace.send(kind, content, commandOptions);
3377
+ } catch (error) {
3378
+ const pending = [...workspace.pending.values()].find(
3379
+ (p) => p.chat === workspace.chat && p.body.kind === kind && p.body.text === content && JSON.stringify(p.body.options) === JSON.stringify(commandOptions)
3380
+ );
3381
+ if (pending)
3382
+ uncertainComposer = {
3383
+ raw: text,
3384
+ chat: pending.chat,
3385
+ request_id: pending.body.request_id
3386
+ };
3387
+ throw error;
3388
+ }
3389
+ }
3390
+ uncertainComposer = void 0;
3391
+ show("Accepted", shell ? "Hosted shell command" : text);
3392
+ return;
3393
+ }
3394
+ const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
3395
+ const cmd = match[1], rest = match[2] ?? "";
3396
+ const tokens = parse3(rest);
3397
+ if (tokens.some((t) => typeof t !== "string"))
3398
+ throw Error(
3399
+ "Use plain arguments; shell operators are supported only with !"
3400
+ );
3401
+ const args = tokens;
3402
+ if (options.runDashboardCommand) {
3403
+ dashboardAbort = new AbortController();
3404
+ try {
3405
+ const handled = await options.runDashboardCommand(text, {
3406
+ client: options.client,
3407
+ task: workspace.task,
3408
+ show,
3409
+ signal: dashboardAbort.signal
3410
+ });
3411
+ dashboardAbort.signal.throwIfAborted();
3412
+ if (handled) return;
3413
+ } finally {
3414
+ dashboardAbort = void 0;
3415
+ }
3416
+ }
3417
+ switch (cmd) {
3418
+ case "help":
3419
+ show("Commands", hostedHelpLines.join("\n"));
3420
+ break;
3421
+ case "reconnect":
3422
+ reconnect();
3423
+ break;
3424
+ case "tasks":
3425
+ show("Tasks", await options.client.request("/v1/tasks"));
3426
+ break;
3427
+ case "task":
3428
+ if (args[0] === "create") {
3429
+ const task2 = await options.client.request(
3430
+ "/v1/tasks",
3431
+ {
3432
+ method: "POST",
3433
+ body: JSON.stringify({
3434
+ name: requireArg(args.slice(1).join(" "), "Enter task name")
3435
+ })
3436
+ }
3437
+ );
3438
+ workspace.setTask(task2.name);
3439
+ } else {
3440
+ const value = requireArg(rest, "Use /task NAME or /task all");
3441
+ const name = value === "all" ? value : normalizeHostedTask(value);
3442
+ if (name !== "all") {
3443
+ const tasks = await options.client.request(
3444
+ "/v1/tasks"
3445
+ );
3446
+ if (!tasks.some((t) => t.name === name))
3447
+ throw Error("Task not found; use /tasks or /task create NAME");
3448
+ }
3449
+ workspace.setTask(name === "all" ? void 0 : name);
3450
+ }
3451
+ redraw();
3452
+ show("Chats", await workspace.list());
3453
+ break;
3454
+ case "chats":
3455
+ show("Chats", await workspace.list(rest));
3456
+ break;
3457
+ case "open":
3458
+ await workspace.open(requireArg(args[0], "Enter chat ID"));
3459
+ await workspace.poll();
3460
+ redraw();
3461
+ break;
3462
+ case "new":
3463
+ await workspace.create(rest || "New chat");
3464
+ await workspace.poll();
3465
+ redraw();
3466
+ break;
3467
+ case "rename":
3468
+ await workspace.rename(requireArg(rest, "Enter chat name"));
3469
+ break;
3470
+ case "fork": {
3471
+ const seq = args.at(-1);
3472
+ const through = seq && /^\d+$/.test(seq) ? Number(seq) : void 0;
3473
+ await workspace.fork(
3474
+ requireArg(
3475
+ through === void 0 ? rest : args.slice(0, -1).join(" "),
3476
+ "Enter fork name"
3477
+ ),
3478
+ through
3479
+ );
3480
+ await workspace.poll();
3481
+ redraw();
3482
+ break;
3483
+ }
3484
+ case "model":
3485
+ case "thinking":
3486
+ if (cmd === "thinking" && !["off", "minimal", "low", "medium", "high", "xhigh"].includes(rest))
3487
+ throw Error("Thinking level: off, minimal, low, medium, high, xhigh");
3488
+ await workspace.send(cmd, requireArg(rest, `Enter ${cmd}`));
3489
+ break;
3490
+ case "compact":
3491
+ case "reload":
3492
+ case "stop":
3493
+ await workspace.send(cmd);
3494
+ break;
3495
+ case "approve":
3496
+ case "reject":
3497
+ await workspace.send(cmd, requireArg(rest, "Enter approval ID"));
3498
+ break;
3499
+ case "steer":
3500
+ case "next":
3501
+ await workspace.send(
3502
+ cmd === "next" ? "follow_up" : "steer",
3503
+ requireArg(rest, "Enter a message")
3504
+ );
3505
+ break;
3506
+ case "retry":
3507
+ await workspace.retry(args[0]);
3508
+ break;
3509
+ case "pending":
3510
+ show(
3511
+ "Uncertain commands",
3512
+ [...workspace.pending.values()].filter(
3513
+ (p) => p.chat === workspace.chat
3514
+ )
3515
+ );
3516
+ break;
3517
+ case "queue":
3518
+ if (args[0] === "edit")
3519
+ await workspace.editQueue(
3520
+ requireArg(args[1], "Enter command ID"),
3521
+ requireArg(args.slice(2).join(" "), "Enter replacement text")
3522
+ );
3523
+ else if (args[0] === "remove") {
3524
+ await workspace.send(
3525
+ "cancel",
3526
+ requireArg(args[1], "Enter command ID")
3527
+ );
3528
+ await workspace.refresh();
3529
+ } else if (args.length)
3530
+ throw Error("Use /queue, /queue edit ID TEXT, or /queue remove ID");
3531
+ else {
3532
+ await workspace.refresh();
3533
+ show("Queue", workspace.detail?.commands ?? []);
3534
+ }
3535
+ break;
3536
+ case "files":
3537
+ await workspace.refresh();
3538
+ show("Files", Object.keys(workspace.detail?.files ?? {}));
3539
+ break;
3540
+ case "view":
3541
+ case "diff":
3542
+ case "download": {
3543
+ await workspace.refresh();
3544
+ const path = requireArg(args[0], "Enter hosted file path");
3545
+ const file = workspace.detail?.files[path];
3546
+ if (!file) throw Error("File not found");
3547
+ if (cmd === "download") {
3548
+ const destination = requireArg(
3549
+ args[1],
3550
+ "Enter an explicit local destination"
3551
+ );
3552
+ await writeExclusive(destination, file.content);
3553
+ show("Saved", destination);
3554
+ } else
3555
+ show(
3556
+ path,
3557
+ cmd === "diff" ? `BEFORE
3558
+ ${file.before ?? "(no previous content)"}
3559
+ AFTER
3560
+ ${file.content}` : file.content
3561
+ );
3562
+ break;
3563
+ }
3564
+ case "skills":
3565
+ await workspace.refresh();
3566
+ show("Skills", workspace.detail?.skills ?? []);
3567
+ break;
3568
+ case "skill": {
3569
+ const action = args[0];
3570
+ if (action === "import") {
3571
+ const skill = await importSkill(
3572
+ requireArg(args[1], "Enter local skill file or folder")
3573
+ );
3574
+ await workspace.skill(skill);
3575
+ show("Imported", skill.name);
3576
+ } else if (action === "reload") await workspace.send("reload");
3577
+ else {
3578
+ await workspace.refresh();
3579
+ const skill = workspace.detail?.skills.find(
3580
+ (s) => s.name === args[1]
3581
+ );
3582
+ if (!skill) throw Error("Skill not found");
3583
+ if (action === "view")
3584
+ show(
3585
+ skill.name,
3586
+ skill.files[args[2] ?? "SKILL.md"] ?? (() => {
3587
+ throw Error("Skill file not found");
3588
+ })()
3589
+ );
3590
+ else if (action === "toggle")
3591
+ await workspace.skill({ ...skill, enabled: !skill.enabled });
3592
+ else if (action === "invoke") {
3593
+ if (!skill.enabled)
3594
+ throw Error("Enable the skill before invoking it");
3595
+ await workspace.send(
3596
+ workspace.busy ? "steer" : "prompt",
3597
+ `/skill:${skill.name} ${args.slice(2).join(" ")}`.trim()
3598
+ );
3599
+ } else
3600
+ throw Error("Use /skill import, view, toggle, invoke, or reload");
3601
+ }
3602
+ break;
3603
+ }
3604
+ case "memory":
3605
+ if (args[0] === "delete")
3606
+ show(
3607
+ "Deleted note",
3608
+ await options.client.request(
3609
+ `/v1/agent/notes/${encodeURIComponent(requireArg(args[1], "Enter note ID"))}`,
3610
+ { method: "DELETE" }
3611
+ )
3612
+ );
3613
+ else {
3614
+ const offset2 = args[0] === "page" ? Number(args[1]) : 0;
3615
+ if (!Number.isSafeInteger(offset2) || offset2 < 0)
3616
+ throw Error("Page offset must be a nonnegative integer");
3617
+ const query2 = args[0] === "page" ? args.slice(2).join(" ") : rest;
3618
+ show(
3619
+ "Memory",
3620
+ await options.client.request(
3621
+ `/v1/agent/notes?limit=100&offset=${offset2}&query=${encodeURIComponent(query2)}`
3622
+ )
3623
+ );
3624
+ }
3625
+ break;
3626
+ case "history": {
3627
+ const from = rest ? Number(rest) : 0;
3628
+ if (!Number.isSafeInteger(from) || from < 0)
3629
+ throw Error("Enter a nonnegative sequence");
3630
+ show(
3631
+ "History",
3632
+ workspace.events.filter((e) => e.seq >= from)
3633
+ );
3634
+ break;
3635
+ }
3636
+ case "export":
3637
+ await writeExclusive(
3638
+ requireArg(args[0], "Enter an explicit local export destination"),
3639
+ JSON.stringify(await workspace.export(), null, 2) + "\n"
3640
+ );
3641
+ show("Exported", args[0]);
3642
+ break;
3643
+ default:
3644
+ throw Error(`Unknown command /${cmd}. Use /help.`);
3645
+ }
3646
+ tui.requestRender();
3647
+ }
3648
+ let submitting = false;
3649
+ let ready = false;
3650
+ async function submit(text) {
3651
+ const generation = workspace.generation;
3652
+ if (!ready || submitting) {
3653
+ input.setText(text);
3654
+ show(
3655
+ !ready ? "Hosted workspace loading" : "Busy",
3656
+ !ready ? "Wait for the selected chat to load. Your draft is preserved." : "Wait for the current request to finish. Your draft is preserved."
3657
+ );
3658
+ return;
3659
+ }
3660
+ submitting = true;
3661
+ show(
3662
+ "Loading",
3663
+ text.startsWith("/") ? text.split(" ")[0] : "Submitting hosted command"
3664
+ );
3665
+ try {
3666
+ await execute(text);
3667
+ if (input.getText() === text) input.setText("");
3668
+ } catch (e) {
3669
+ if (!done) {
3670
+ redraw();
3671
+ if (generation === workspace.generation && !input.getText())
3672
+ input.setText(text);
3673
+ show(
3674
+ e instanceof Error && e.name === "AbortError" ? "Transfer paused" : "Error",
3675
+ e instanceof Error && e.name === "AbortError" ? "Local request interrupted. Upload receipts are preserved; use /uploads retry to resume. Hosted jobs continue." : e instanceof Error ? e.message : String(e)
3676
+ );
3677
+ }
3678
+ } finally {
3679
+ submitting = false;
3680
+ if (generation !== workspace.generation && !/^\/(task|open|new|fork|quit|exit)(?:\s|$)/.test(text)) {
3681
+ screen.lines = [];
3682
+ seenGeneration = workspace.generation;
3683
+ seenCount = 0;
3684
+ screen.offset = 0;
3685
+ streams.clear();
3686
+ }
3687
+ tui.requestRender();
3688
+ }
3689
+ }
3690
+ tui.addChild(screen);
3691
+ tui.addChild(input);
3692
+ input.onSubmit = (text) => void submit(text);
3693
+ tui.addInputListener((data) => {
3694
+ if (matchesKey2(data, "escape") && dashboardAbort) {
3695
+ dashboardAbort.abort();
3696
+ show(
3697
+ "Transfer paused",
3698
+ "Interrupting the local dashboard request; upload receipts are preserved."
3699
+ );
3700
+ return { consume: true };
3701
+ }
3702
+ if (ready && !dashboardAbort && workspace.busy && (matchesKey2(data, "escape") || matchesKey2(data, "ctrl+c"))) {
3703
+ void workspace.send("stop").then(
3704
+ () => show(
3705
+ "Stop requested",
3706
+ "Stopping hosted work; the chat remains open."
3707
+ ),
3708
+ (error) => show(
3709
+ "Stop failed",
3710
+ error instanceof Error ? error.message : String(error)
3711
+ )
3712
+ );
3713
+ return { consume: true };
3714
+ }
3715
+ if (matchesKey2(data, "ctrl+c")) {
3716
+ quit();
3717
+ return { consume: true };
3718
+ }
3719
+ if (matchesKey2(data, "pageUp") || matchesKey2(data, "pageDown")) {
3720
+ screen.offset = Math.max(
3721
+ 0,
3722
+ Math.min(
3723
+ screen.lines.length,
3724
+ screen.offset + (matchesKey2(data, "pageUp") ? 10 : -10)
3725
+ )
3726
+ );
3727
+ tui.requestRender();
3728
+ return { consume: true };
3729
+ }
3730
+ return void 0;
3731
+ });
3732
+ tui.setFocus(input);
3733
+ tui.start();
3734
+ show(
3735
+ "Ready",
3736
+ "Use /help for hosted commands. No hosted chat selected; /chats, /open ID or /new NAME."
3737
+ );
3738
+ if (options.chat)
3739
+ try {
3740
+ await workspace.open(options.chat);
3741
+ await workspace.poll();
3742
+ redraw();
3743
+ } catch (e) {
3744
+ show("Error", e instanceof Error ? e.message : String(e));
3745
+ }
3746
+ ready = true;
3747
+ options.onReady?.({ workspace, submit, quit });
3748
+ void poll();
3749
+ return await result;
3750
+ }
3751
+
3752
+ // sdk-markdown:/Users/kapavarapu/Desktop/ashr-manifold/.worktrees/cli-scoped-name/docs/sdk.md
3753
+ var sdk_default = '# Manifold Python SDK\n\n## Quickstart\n\nCapture calls, train candidates, and serve promoted models. Reviewed against repository code on September 15, 2026; deployed API/worker and installed SDK versions may differ.\n\n### Install\n\n```bash\npip install ashr-manifold # Python 3.10+; from a checkout: pip install ./sdk\nexport ASHR_BASE_URL="https://api.manifold.ashr.io"\nexport ASHR_API_KEY="ask_..."\n```\n\nCreate a service key in the platform\u2019s API keys page. The platform renders this guide under SDK docs.\n\n```python\nfrom ashr_sdk import AshrClient\nashr = AshrClient() # reads both environment variables\n```\n\nExamples use placeholder IDs. Replace them with objects in your tenant; keep one client open and close it when finished.\n\n### Serve a promoted model\n\nCall a promoted model with the Anthropic- or OpenAI-style client, pointed at\nManifold\'s base URL with the task name as `model`. The capture ID comes back in\nthe `X-Manifold-Interaction-Id` response header:\n\n```python\nimport os\nfrom anthropic import Anthropic\n\nmodel_client = Anthropic(\n api_key=os.environ["ASHR_API_KEY"],\n base_url=os.environ["ASHR_BASE_URL"].rstrip("/"),\n)\nserved = model_client.messages.with_raw_response.create(\n model="game_generation", # a promoted task; add "@alias" to pin one\n max_tokens=1024,\n messages=[{"role": "user", "content": "Build an ice cavern."}],\n)\nmessage = served.parse()\ninteraction_id = served.headers.get("x-manifold-interaction-id")\nprint(message.content[0].text, interaction_id)\n```\n\nRequires a promoted, loadable artifact. Manifold captures served completions\nautomatically when telemetry succeeds. See Host a trained model for the OpenAI\nclient, tools, streaming, and limits.\n\n### Wrap an existing client\n\nFor models served elsewhere, install the provider package and wrap its client:\n\n```python\nimport anthropic\nimport ashr_sdk as capture\n\ncapture.init(task="game_generation")\nclient = capture.wrap(anthropic.Anthropic()) # your provider credentials\nresponse = client.messages.create(\n model="claude-sonnet-4-5", max_tokens=512,\n messages=[{"role": "user", "content": "Design a level."}],\n)\ncapture.flush()\n```\n\nSupports OpenAI and Anthropic sync, async, and streaming calls. Capture failures warn without failing the provider call. Flush before short-lived processes exit.\n\n## Capture production behavior\n\nProduction shows captured calls, model lineage, review coverage, latency, and cost. Use the SDK to capture calls and record feedback.\n\n### Group calls with a trace\n\n```python\nwith ashr.trace("support_routing", client_ref="ticket-4821"):\n interaction_id = ashr.log(\n task="support_routing", model_id="m_router",\n input={"subject": "Cannot reset password"},\n output={"queue": "identity"}, latency_ms=210,\n input_tokens=86, output_tokens=18,\n decisions=[{\n "field_path": "queue", "value": "identity", "confidence": 0.91,\n "alternatives": [{"value": "billing", "probability": 0.07}],\n "source_citation": "ticket subject",\n }],\n )\n```\n\n`log()` requires a registered `model_id`. The wrapper and `log_batch()` also support model-name auto-registration. `client_ref` is your request ID; use dotted `field_path` values for nested decisions.\n\n### Attach artifacts\n\n```python\nartifact = ashr.artifacts.register(\n name="ticket attachment", kind="document",\n bucket="my-evidence", object_key="tickets/4821.pdf",\n)\ninteraction_id = ashr.log(\n "support_routing", "m_router", {"ticket": "4821"}, {"queue": "identity"},\n artifacts=[artifact["id"]],\n)\nurl = ashr.artifacts.access_url(artifact["id"], expires_in=900)\n```\n\n`artifacts.list()` and `get(id)` read registered evidence. These are object-store artifacts; discovered training weights use the download API under Run model experiments.\n\n## Build feedback and evidence\n\n### Record human feedback\n\n```python\nashr.feedback(\n interaction_id, "corrected", corrected_output={"queue": "account_recovery"},\n diff={"queue": ["identity", "account_recovery"]},\n by="reviewer-142", reason="routing_error",\n)\n```\n\nUse `confirmed` for correct output, `corrected` with the complete desired output, or `rejected`. Rejected output is never a positive target; it can supply a DPO negative.\n\n### Report validator checks\n\n```python\nashr.checks([{\n "interaction_id": interaction_id,\n "checks": {"schema_valid": True, "correct_queue": False},\n "details": {"correct_queue": "expected account_recovery"},\n}], reviewer="routing-validator")\n```\n\nAll checks passing confirms the observation; any failure rejects it. Report measured results only.\n\n### Import labeled data\n\n```python\nresult = ashr.datasets.import_examples(\n task="support_routing", model_id="m_router",\n examples=[{"subject": "Reset password", "queue": "identity"}],\n input_fn=lambda row: {"subject": row["subject"]},\n output_fn=lambda row: {"queue": row["queue"]},\n metadata_fn=lambda row: {"source": "reviewed_tickets"},\n batch_size=25, freeze=False,\n)\n```\n\nAccepts any iterable, including a Hugging Face Dataset. Batches allow 1\u2013500 rows; use 25\u201350 for large payloads. `log_batch()` accepts `interaction` plus optional `feedback`; `interaction.model` can register by name.\n\n`input.source` is reserved for `manifold.source/v1`; put ordinary provenance in `input.metadata`. The batch API accepts historical `created_at` values from 2020 through now and normalizes aware timestamps to UTC.\n\n### Freeze and resume safely\n\n```python\ndataset = ashr.datasets.freeze("support_routing", holdout_fraction=0.1)\ndataset_id = dataset["id"]\nversions = ashr.datasets.list()\n```\n\nFreeze records immutable membership and holdout splits. Alternatively, set `freeze=True` on import and use `result["dataset"]["id"]`; do not freeze again without new labeled data (400). After an import failure, inspect `failed_index`/`completed` in the API error. The SDK exposes that detail as `AshrError` text, not a cross-batch resume cursor. Track your batches. After an ambiguous import or freeze timeout, reconcile accepted rows or dataset versions before retrying; a concurrent freeze returns 409.\n\n## Run model experiments\n\nExperiments trains candidates on the same frozen dataset and compares their evaluation evidence. Use the SDK for training configuration, cancellation, retries, and deployment rollback.\n\n### Create an experiment\n\n```python\nexperiment = ashr.experiments.create(\n name="support-router-v5", task="support_routing", dataset="ds_support_v5",\n models=["org/router-small", {\n "model_ref": "org/router-large", "revision": "PINNED_REVISION",\n "recipe": "dpo_lora", "config": {"gpu": "L40S", "dpo_beta": 0.1},\n }],\n training_config={"epochs": 1, "eval_batch_size": 8, "eval_max_tokens": 256},\n primary_metric="field_f1", gates={"field_f1": 0.8, "schema_validity": 0.95},\n evaluator_config={"required_fields": ["queue"]},\n)\nprint(experiment.id, experiment.estimate())\nexperiment.start(idempotency_key="support-router-v5-start")\nexperiment.wait(timeout=21600, poll_interval=10)\nresults = experiment.comparison()\nprint(experiment.failures())\n```\n\nPin each model revision. Candidate settings override experiment defaults. SFT uses positive targets; DPO needs chosen/rejected pairs. Gates require measured metrics and cannot lower platform floors. An estimate is directional, not a bill.\n\n### Track runs and recover failures\n\n```python\nexperiment.refresh()\nfor candidate in experiment.candidates:\n for run in candidate["runs"]:\n current = ashr.training_runs.get(run["id"])\n print(current["status"], current.get("error"))\nstale_runs = ashr.training_runs.stale(minutes=15)\n# After inspecting a specific run:\n# ashr.training_runs.cancel(run_id)\n# retried = ashr.training_runs.retry(run_id)\n```\n\n`wait()` includes failed/cancelled terminal states; inspect errors and gates. Save the experiment ID before starting. Retry `start()` on `experiments.get(id)` with the same key. Repeating `experiments.run()` creates a new experiment. `experiments.list()` lists existing experiments.\n\nThe worker enables gradient checkpointing by default. For OOM, reduce `max_length` or choose a supported larger GPU. `retain_failed_artifacts=True` retains a written final artifact after a gate miss; it cannot recover unwritten weights.\n\n### Own the weights\n\n```python\nresume = ashr.training_runs.weights_url(run_id)\nadapter = ashr.training_runs.weights_url(run_id, format="adapter")\nexact = ashr.training_runs.weights_url(run_id, checkpoint=600)\nfinal = ashr.training_runs.weights_url(run_id, checkpoint=None, format="adapter")\nprint(resume["url"])\n```\n\nSigned downloads expire after one hour by default. `latest` resolves at download time; numbered steps pin selection. Resume archives include weights, optimizer, scheduler, RNG, and trainer state; adapter archives are for inference. A URL can be issued before files exist; the download may return 409 before the first save. Defaults: `save_steps=100`, `save_total_limit=2`. Download needed steps before pruning.\n\n```python\nimport os\nashr.training_runs.export_to_huggingface(\n run_id, repo_id="acme/support-router-v5", hf_token=os.environ["HF_TOKEN"],\n)\n```\n\nThe token is forwarded for upload, not stored. The response means queued, not uploaded. Load adapter files with PEFT and the matching base model.\n\n### Discovered artifacts\n\nArtifacts lists checkpoints/adapters from Modal volumes, including failed runs. Filter for weights; inspect written time, adoption status, and download size, which includes nested files. Discovery does not mean evaluated, registered, or promoted.\n\nFor a row\u2019s exact `volume` and `path`, use its download button/API command or `POST /v1/modal-artifacts/download-url` with `format="resume"` or `"adapter"`. Your session or service key authorizes the grant; no Modal token is needed. The SDK has no dedicated helper for this endpoint.\n\n## Run the full learning cycle\n\n### Run a cycle\n\n```python\nreport = ashr.learning_loops.run(\n task="support_routing",\n candidates=[{"model_source_id": "src_router", "recipe": "sft_lora"}],\n eval_config={"primary_metric": "field_f1"},\n halving={"metric": "proxy_score", "at_progress": 0.5, "kill_fraction": 0.5},\n alias="production", timeout_seconds=300,\n)\nprint(report["killed"], report["winner"], report["promoted"], report["reason"])\n```\n\nUses registered model-source IDs. Freezes/reuses evidence, builds an environment, trains, halves, evaluates, and promotes only a passing winner that beats the incumbent. Halving is recorded before launch. This synchronous request needs sufficient client/gateway time; inspect runs after a timeout before starting another cycle.\n\n### Test without GPUs\n\nUse `MANIFOLD_TRAINING_BACKEND=mock` on a local test API. It exercises callbacks, gates, lineage, and cancellation with simulated training. `backend/scripts/learning_loop_demo.py` runs the local example. Mock scores do not establish model quality.\n\n## Continuous learning configuration\n\n### Build an environment\n\n```python\nenvironment = ashr.environments.build(\n task="support_routing", dataset_id="ds_support_v5",\n reward_spec={"resolved": 1, "policy_violation": -5},\n)\nashr.learning_loops.configure(\n task="support_routing", environment_id=environment["id"],\n config={"trigger_after_reviewed": 500, "trigger_every_days": 7,\n "benchmark_incumbent": True, "auto_train": True},\n)\n```\n\nThese calls store environment and retraining configuration. Trigger scheduling remains in progress. Read configurations with `environments.list()` and `learning_loops.list()`.\n\n### Application-side routing\n\n```python\nashr.router.configure(\n task="support_routing", incumbent_model_id="m_current",\n challenger_model_id="m_candidate", traffic_percent=5,\n)\nassignment = ashr.router.decide(task="support_routing", request_key="ticket-4821")\n# Your application serves assignment["model_id"], then records the actual result:\nashr.router.outcome(assignment["id"], {"resolved": True, "reward": 1})\nashr.router.set_traffic("support_routing", 0)\n```\n\nAssignment is deterministic per policy/key. For managed serving, also promote the challenger to `canary` as described below.\n\n## Host a trained model\n\nRequires a promoted task alias backed by a loadable trained artifact. Installing the SDK or finding weights does not create a deployment.\n\n### Choose a client\n\nOpenAI uses the API root plus `/v1`; Anthropic and `AshrClient` use the API root. Install provider packages separately.\n\n```python\nimport os\nfrom openai import OpenAI\n\nclient = OpenAI(api_key=os.environ["ASHR_API_KEY"],\n base_url=os.environ["ASHR_BASE_URL"].rstrip("/") + "/v1")\nresponse = client.chat.completions.create(\n model="game_generation", max_completion_tokens=1024,\n messages=[{"role": "user", "content": "Build an ice cavern."}],\n)\nprint(response.choices[0].message.content)\n```\n\n```python\nfrom anthropic import Anthropic\n\nclient = Anthropic(api_key=os.environ["ASHR_API_KEY"],\n base_url=os.environ["ASHR_BASE_URL"].rstrip("/"))\nmessage = client.messages.create(\n model="game_generation", max_tokens=1024,\n system="Return a concise level design.",\n messages=[{"role": "user", "content": "Build an ice cavern."}],\n)\nprint(message.content[0].text)\n```\n\nTools and streaming work on either client. From OpenAI, declare `tools` and stream with `stream=True`:\n\n```python\nopenai_client = OpenAI(api_key=os.environ["ASHR_API_KEY"],\n base_url=os.environ["ASHR_BASE_URL"].rstrip("/") + "/v1")\nstream = openai_client.chat.completions.create(\n model="game_generation", stream=True,\n messages=[{"role": "user", "content": "Design a level, then call the tool."}],\n tools=[{"type": "function", "function": {\n "name": "save_level",\n "parameters": {"type": "object", "properties": {"name": {"type": "string"}}}}}],\n)\nstream_text = "".join(\n chunk.choices[0].delta.content or "" for chunk in stream)\n```\n\nA non-streaming tools call returns `tool_calls` on the message with `finish_reason="tool_calls"`; send each result back as a `{"role": "tool", "tool_call_id": ..., "content": ...}` message. The Anthropic client uses `tools` with `input_schema` and receives `tool_use` content blocks.\n\nNative SDK: `ashr.infer(task, input, alias="production", max_new_tokens=1024)` returns the parsed `output`, per-field `decisions`, `usage`, `deployment`, and `interaction_id`. Read `deployment.alias` for the actual selection; the top-level alias is the requested one.\n\n`ashr.infer` (and `POST /v1/inference/{task}`) is **deprecated for chat and agentic models** \u2014 use the Anthropic or OpenAI client shown above, which serve the hosted model directly with tools and streaming. It remains supported, and is the right choice, for structured-output tasks (e.g. classification) that need the per-field `decisions` capture the transparent surfaces don\'t produce. It is not being removed.\n\n### Supported requests and generation limits\n\n- Model: `task` or `task@alias`; default alias is `production`.\n- Both surfaces are served by the same hosted model \u2014 OpenAI (`/v1/chat/completions`) is translated to Anthropic Messages at the edge \u2014 so the same model key works from either SDK. `system`/`user`/`assistant`/`tool` roles, `temperature`, `top_p`, and `stop` are supported; `max_completion_tokens` takes precedence over `max_tokens`.\n- **Tools and streaming are supported on both surfaces.** OpenAI `tools` come back as `tool_calls` (with `finish_reason: "tool_calls"`), `tool` messages carry results back, and `stream: true` yields `chat.completion.chunk` events including tool-call deltas and final usage. The Anthropic surface forwards request and response bodies unchanged \u2014 tools, thinking, and streams \u2014 subject to the hosted model\u2019s capabilities.\n- OpenAI multimodal (image) content is not supported; a message whose content is not text is rejected. Older API deployments support only text and reject streaming; verify your deployed revision.\n- Token limits must be positive. Omitted limits use remaining model context; the Anthropic Python client requires `max_tokens`. Configure client timeouts for your budget.\n- Platform keys (`ask_`) support platform operations. Model keys (`msk_`) grant inference for one task; use a platform key for feedback and training.\n\n### Completion evidence and feedback\n\nNo capture wrapper is needed for hosted calls. OpenAI responses add `manifold` metadata:\n\n```python\nmetadata = response.model_dump()["manifold"]\ninteraction_id = metadata["interaction_id"]\n# After actual review, and only if interaction_id is not None:\n# ashr.feedback(interaction_id, "confirmed", by="reviewer-142")\n```\n\nIncludes task, actual alias, and deployment ID. Both surfaces return the capture ID in the `X-Manifold-Interaction-Id` header on non-streaming responses (read it via each client\u2019s raw-response interface); the OpenAI non-streaming body also carries it in the `manifold` block above. Streams return no capture ID; their capture retains text deltas, not tool/thinking deltas, even though those events reach the caller. Capture failure returns a null interaction ID without failing a completed answer. Do not attach feedback to a missing ID.\n\nOpenAI finish reasons: `stop`/`length`/`tool_calls`; null means missing/unrecognized worker evidence. Anthropic stop reasons are passed through from the hosted model. Inspect truncated output. Literal text is retained in `sampling_params.raw_text` when available; retained context is not automatically supervised training data.\n\n### Canary rollout and rollback\n\n```python\nincumbent = ashr.deployments.resolve("support_routing", alias="production")\ncanary = experiment.promote(candidate_id, alias="canary")\nashr.router.configure(\n task="support_routing", incumbent_model_id=incumbent["model_id"],\n challenger_model_id=canary["model_id"], traffic_percent=5,\n)\nashr.router.set_traffic("support_routing", 0) # stop automatic canary selection\n```\n\nUse a passing candidate ID. Production requests participate in the split; explicit `@canary` bypasses it, even at zero traffic. OpenAI `user` or Anthropic `metadata.user_id` makes assignment sticky; without a key it is per request. Native `infer()` has no request-key parameter.\n\nAutomatic routing falls back to production if canary is absent, unavailable, or mismatched. Check returned deployment metadata. Alias-history rollback is separate: `deployments.rollback(deployment_id)`.\n\n### Hosting failures and separate endpoints\n\n- 401: invalid key/environment. 409: alias lacks a loadable artifact.\n- 400/422: local request validation failed. Anthropic upstream failures become 502, or an error event after a stream opens. 502 also indicates inference gateway failure.\n- Timeout: completion may be unknown; a retry can duplicate work.\n\nVerify an authenticated request through the intended alias; health and alias existence do not prove GPU readiness. The standalone `ashr-model-api` uses its own fixed model and endpoint key, without Manifold tenant lookup or billing. Obtain its details from the operator; do not assume those calls appear in Production.\n\n## Deploy and monitor\n\n### Promote and resolve\n\nAfter reviewing metrics and gates, select a passing candidate:\n\n```python\ndeployment = experiment.promote(candidate_id, alias="production")\nproduction = ashr.deployments.resolve("support_routing")\n# To restore the previous revision:\n# ashr.deployments.rollback(deployment["id"])\n```\n\n`deployments.list(task=...)` lists aliases; `get(id)` reads deployment details. Training completion is not serving readiness or evidence of quality.\n\n### Read production metrics\n\n```python\nhealth = ashr.monitoring.model("m_router", days=30)\ndaily = ashr.monitoring.timeseries("m_router", days=30)\ncomparison = ashr.monitoring.compare("m_previous", "m_router", days=30)\ndrift = ashr.monitoring.drift("m_router", days=30, minimum_reviewed=20)\n```\n\nInspect volume, review coverage, correction rate, and latency. Reviewer rates use reviewed observations; pending output is not counted as correct.\n\n### Create an alert\n\n```python\npolicy = ashr.monitoring.create_alert(\n name="Routing correction rate", model_id="m_router",\n metric="correction_rate", operator="gte", threshold=0.08,\n window_days=7, minimum_observations=50,\n)\npolicies = ashr.monitoring.alert_policies()\nalerts = ashr.monitoring.alerts(limit=100)\n```\n\n`evaluate_alerts()` evaluates policies now. Scheduled checks depend on the deployed monitoring worker.\n\n## Pipelines and progress\n\nPipeline shows stage evidence; Data sources shows owned/shared source access. Use an ID from `pipelines.list()`; `manifold_loop` exists only after it has been seeded.\n\n### Reading progress\n\n```python\npipelines = ashr.pipelines.list()\nstatus = ashr.pipelines.status("manifold_loop", task="document_parsing")\nprint(status.progress, status.stage("capture"))\nfresh = status.refresh(task="document_parsing")\n```\n\n- `progress.unbroken`: completed prefix; `complete`: all completed stages, including those past a gap.\n- `next` and `startable`: next gap and stages whose dependencies permit running.\n- `status.complete`, `blocked`, `failed`, `unknown`, `stale`, `startable`, and `gates`: filtered stage lists. `state(id)` reads one state.\n- `unknown`: probe could not run, not pending. Freshness uses `observedAt`, `ageSeconds`, `stale`, and each probe\u2019s TTL. `refresh=True` re-probes.\n\n### Writing a definition\n\n```python\nashr.pipelines.put({\n "schemaVersion": 1, "id": "my_pipeline", "label": "My pipeline",\n "phases": [{"id": "acquire", "label": "Acquire", "stages": [{\n "id": "reachable", "title": "Data is reachable",\n "probe": {\n "kind": "object_store", "storeRef": "corpus", "prefix": "sessions/",\n "maxObjects": 10000,\n "extract": {"objects": {"path": "$.Contents[*]", "agg": "count"}},\n "complete": {"gte": ["objects", 1]},\n "detail": "{objects} objects reachable", "ttlSeconds": 3600,\n },\n }]}],\n})\n```\n\n`put()` validates tenant-safe probes and invalidates cached readings. `get(id)`, `delete(id)`, and `registries()` manage definitions and allowed probe/query names. Full schema: `docs/pipelines.md`.\n\n### Data sources\n\n```python\nashr.pipelines.data_sources.put(\n "corpus", provider="s3_compatible", bucket="my-corpus",\n endpoint="https://nyc3.digitaloceanspaces.com", allowed_prefixes=["sessions/"],\n credential_ref="awssm://YOUR_SECRET_REFERENCE",\n)\nsources = ashr.pipelines.data_sources.list()\naccess = ashr.pipelines.data_sources.check("corpus")\nprint(access)\n```\n\nSupply either a secret reference or `credentials={"access_key_id": ..., "secret_access_key": ...}`. Credentials are accepted once and never returned. Empty `allowed_prefixes` grants no access; probes name the source alias.\n\nFor shared data, set `shared_from` and optionally `shared_from_tenant_id`; resolved `sharing.from_tenant` takes precedence over the label. `credential.name` is a pointer, not secret material. `delete(alias)` removes a source.\n\nA configured credential does not prove access. Inspect `reachable`, `error`, and `truncated`; a capped listing is a lower bound. S3-compatible checks list metadata only. GCS, Azure Blob, and HTTPS can be registered but report unsupported listing, so their probes read `unknown`.\n\n## Reliability and lifecycle\n\n### Handle errors\n\n```python\nfrom ashr_sdk import AshrClient, AshrError\n\ntry:\n with AshrClient(timeout=180) as ashr:\n health = ashr.monitoring.model("m_router")\nexcept AshrError as error:\n print(error.status_code, error.request_id, str(error))\n```\n\nInclude request IDs when reporting failures. API errors and transport failures raise `AshrError`; transport failures may have no status or request ID. Reconcile timed-out writes before retrying.\n\n### Manage the client\n\nUse a context manager for scripts, or reuse one `AshrClient` and call `close()` at shutdown. Module-level capture uses `flush()` before exit. Never place service keys in browser code; the platform uses Clerk through its same-origin API.\n';
3754
+
3755
+ // src/hosted/sdk-reference.ts
3756
+ var documentTitle = /^# (.+)$/m.exec(sdk_default)?.[1] ?? "Manifold Python SDK";
3757
+ var sections = [];
3758
+ var offset = 0;
3759
+ var fenced = false;
3760
+ for (const line2 of sdk_default.split(/(?<=\n)/)) {
3761
+ if (line2.startsWith("```")) fenced = !fenced;
3762
+ const heading2 = !fenced ? /^## ([^\r\n]+)\r?\n?$/.exec(line2) : null;
3763
+ if (heading2) {
3764
+ const previous = sections.at(-1);
3765
+ if (previous) previous.end = offset;
3766
+ const title = heading2[1];
3767
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
3768
+ sections.push({ title, slug, start: offset, end: sdk_default.length });
3769
+ }
3770
+ offset += line2.length;
3771
+ }
3772
+ function sdkReference(topic) {
3773
+ const selected = topic || "quickstart";
3774
+ if (selected === "all") return { title: documentTitle, content: sdk_default };
3775
+ if (selected === "index")
3776
+ return {
3777
+ title: documentTitle,
3778
+ content: sections.map((section2) => `- ${section2.title}: /sdk ${section2.slug}`).join("\n") + "\n\n/sdk all reads the complete guide. /sdk defaults to Quickstart."
3779
+ };
3780
+ const section = sections.find((candidate) => candidate.slug === selected);
3781
+ if (!section) throw Error(`Unknown SDK topic '${selected}'. Use /sdk index.`);
3782
+ return {
3783
+ title: section.title,
3784
+ content: sdk_default.slice(section.start, section.end)
3785
+ };
3786
+ }
3787
+
3788
+ // src/hosted/dashboard-sample.ts
3789
+ import { open as open2, readFile as readFile2, rename, unlink } from "node:fs/promises";
3790
+ import { randomUUID as randomUUID3 } from "node:crypto";
3791
+ async function sampleModel(context, model, prompt, maxTokens, temperature, file, show) {
3792
+ const stream = context.client.requestStream;
3793
+ if (!stream) throw Error("This client does not support streaming samples");
3794
+ let conversation = { version: 1, task: model, api_origin: context.client.apiOrigin, messages: [], turns: [] };
3795
+ let resumed = false;
3796
+ if (file) {
3797
+ if (!context.client.apiOrigin) throw Error("Conversation requires a client with a verified API origin");
3798
+ try {
3799
+ conversation = JSON.parse(await readFile2(file, "utf8"));
3800
+ resumed = true;
3801
+ } catch (error) {
3802
+ if (error.code !== "ENOENT") throw Error("Conversation must be a readable terminal sample JSON file");
3803
+ const handle = await open2(file, "wx", 384);
3804
+ await handle.close();
3805
+ }
3806
+ if (conversation.version !== 1 || conversation.task !== model || !Array.isArray(conversation.messages) || !Array.isArray(conversation.turns) || conversation.messages.some((m) => !m || !["user", "assistant"].includes(m.role) || typeof m.content !== "string")) throw Error("Conversation task or format does not match this sample");
3807
+ if (resumed) {
3808
+ if (conversation.api_origin !== context.client.apiOrigin) throw Error("Conversation API origin does not match; no history was sent");
3809
+ }
3810
+ }
3811
+ const persist = async () => {
3812
+ if (!file) return;
3813
+ const temp = `${file}.${randomUUID3()}.tmp`;
3814
+ const handle = await open2(temp, "wx", 384);
3815
+ try {
3816
+ await handle.writeFile(JSON.stringify(conversation, null, 2) + "\n");
3817
+ await handle.sync();
3818
+ await handle.close();
3819
+ await rename(temp, file);
3820
+ } catch (error) {
3821
+ await handle.close().catch(() => {
3822
+ });
3823
+ await unlink(temp).catch(() => {
3824
+ });
3825
+ throw error;
3826
+ }
3827
+ };
3828
+ context.signal?.throwIfAborted();
3829
+ conversation.messages.push({ role: "user", content: prompt });
3830
+ const messages = [...conversation.messages];
3831
+ const assistant = { role: "assistant", content: "" };
3832
+ conversation.messages.push(assistant);
3833
+ const turn = { events: [], status: "streaming" };
3834
+ conversation.turns.push(turn);
3835
+ await persist();
3836
+ try {
3837
+ const response = await stream.call(context.client, "/v1/messages", { method: "POST", signal: context.signal, body: JSON.stringify({ model, max_tokens: maxTokens, temperature, stream: true, messages }) });
3838
+ if (!response.body) throw Error("Sample response has no stream");
3839
+ const reader = response.body.getReader();
3840
+ const decoder = new TextDecoder();
3841
+ let buffer = "";
3842
+ let complete = false;
3843
+ const cancel = () => {
3844
+ void reader.cancel(context.signal?.reason);
3845
+ };
3846
+ context.signal?.addEventListener("abort", cancel, { once: true });
3847
+ const event = async (block) => {
3848
+ const data = block.split("\n").filter((line2) => line2.startsWith("data:")).map((line2) => line2.slice(5).trimStart()).join("\n");
3849
+ if (!data) return;
3850
+ if (data === "[DONE]") {
3851
+ complete = true;
3852
+ return;
3853
+ }
3854
+ const value = JSON.parse(data);
3855
+ turn.events.push(value);
3856
+ if (value.type === "error" || value.error) throw Error("Model stream error: " + JSON.stringify(value.error ?? value));
3857
+ const text = value.type === "content_block_delta" && value.delta?.type === "text_delta" ? value.delta.text : value.type === "content_block_start" ? value.content_block?.text : value.choices?.[0]?.delta?.content;
3858
+ if (typeof text === "string" && text) {
3859
+ assistant.content += text;
3860
+ show("Sample text", text);
3861
+ }
3862
+ if (value.type === "message_stop") complete = true;
3863
+ await persist();
3864
+ };
3865
+ try {
3866
+ while (true) {
3867
+ context.signal?.throwIfAborted();
3868
+ const part = await reader.read();
3869
+ buffer += decoder.decode(part.value, { stream: !part.done });
3870
+ buffer = buffer.replace(/\r\n/g, "\n");
3871
+ let split;
3872
+ while ((split = buffer.indexOf("\n\n")) >= 0) {
3873
+ await event(buffer.slice(0, split));
3874
+ buffer = buffer.slice(split + 2);
3875
+ }
3876
+ if (part.done) break;
3877
+ }
3878
+ context.signal?.throwIfAborted();
3879
+ if (buffer.trim()) await event(buffer);
3880
+ if (!complete) throw Error("Model stream incomplete; partial conversation retained. A new sample is a new paid request.");
3881
+ } finally {
3882
+ context.signal?.removeEventListener("abort", cancel);
3883
+ await reader.cancel().catch(() => {
3884
+ });
3885
+ }
3886
+ turn.status = "complete";
3887
+ await persist();
3888
+ show("Model sample", { task: model, message: assistant, events: turn.events, conversation: file ?? null });
3889
+ } catch (error) {
3890
+ turn.status = "interrupted";
3891
+ if (!assistant.content) conversation.messages.pop();
3892
+ await persist();
3893
+ const message = error instanceof Error ? error.message : String(error);
3894
+ throw Error(message);
3895
+ }
3896
+ }
3897
+
3898
+ // src/hosted/dashboard.ts
3899
+ import { parse as parse4 } from "shell-quote";
3900
+ import { readFile as readFile4 } from "node:fs/promises";
3901
+
3902
+ // src/hosted/dashboard-files.ts
3903
+ import { createHash as createHash4 } from "node:crypto";
3904
+ import { createReadStream } from "node:fs";
3905
+ import { open as open3, readFile as readFile3, rename as rename2, stat, unlink as unlink2, writeFile } from "node:fs/promises";
3906
+ import { basename as basename4, resolve as resolve8 } from "node:path";
3907
+ import { Readable } from "node:stream";
3908
+ import { pipeline } from "node:stream/promises";
3909
+ function transferUrl(value) {
3910
+ const url = new URL(value);
3911
+ if (url.username || url.password || !(url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) throw Error("Transfer URL must use HTTPS (or loopback for local fixtures)");
3912
+ return url.href;
3913
+ }
3914
+ async function downloadArtifact(client, path, body, output, show, signal) {
3915
+ signal?.throwIfAborted();
3916
+ const destination = resolve8(output);
3917
+ const handle = await open3(destination, "wx", 384);
3918
+ try {
3919
+ const grant = await client.request(path, { method: "POST", body: JSON.stringify(body), ...signal ? { signal } : {} });
3920
+ const response = await fetch(transferUrl(grant.url), { redirect: "error", signal });
3921
+ if (!response.ok || !response.body) throw Error(`Artifact download failed (${response.status})`);
3922
+ await pipeline(Readable.fromWeb(response.body), handle.createWriteStream(), { signal });
3923
+ show("Artifact downloaded", { output: destination, bytes: (await stat(destination)).size });
3924
+ } catch (error) {
3925
+ await handle.close().catch(() => {
3926
+ });
3927
+ await unlink2(destination).catch(() => {
3928
+ });
3929
+ throw error;
3930
+ } finally {
3931
+ await handle.close().catch(() => {
3932
+ });
3933
+ }
3934
+ }
3935
+ async function fingerprint(file, signal) {
3936
+ const info = await stat(file);
3937
+ if (!info.isFile() || info.size < 1) throw Error("Upload requires a nonempty regular file");
3938
+ const hash = createHash4("sha256");
3939
+ for await (const chunk of createReadStream(file, { signal })) {
3940
+ signal?.throwIfAborted();
3941
+ hash.update(chunk);
3942
+ }
3943
+ return { size: info.size, sha256: hash.digest("hex") };
3944
+ }
3945
+ async function save(path, receipt) {
3946
+ const temporary = path + ".tmp";
3947
+ await writeFile(temporary, JSON.stringify(receipt, null, 2) + "\n", { flag: "wx", mode: 384 });
3948
+ try {
3949
+ await rename2(temporary, path);
3950
+ } catch (error) {
3951
+ await unlink2(temporary).catch(() => {
3952
+ });
3953
+ throw error;
3954
+ }
3955
+ }
3956
+ async function uploadFile(action, input, receiptPath, client, show, signal) {
3957
+ signal?.throwIfAborted();
3958
+ const path = resolve8(action === "retry" ? input : receiptPath);
3959
+ let receipt;
3960
+ if (action === "retry") {
3961
+ receipt = JSON.parse(await readFile3(path, "utf8"));
3962
+ if (receipt.version !== 1 || !receipt.file || !Number.isInteger(receipt.size) || !/^[a-f0-9]{64}$/.test(receipt.sha256)) throw Error("Invalid upload receipt");
3963
+ if (receipt.state === "complete") {
3964
+ show("Upload already complete", { receipt: path, key: receipt.key });
3965
+ return;
3966
+ }
3967
+ if (receipt.state !== "uploading") throw Error(`Upload is ${receipt.state}; last response is uncertain. Inspect upload status before retrying; creation/completion will not be repeated.`);
3968
+ const identity = await fingerprint(receipt.file, signal);
3969
+ if (identity.size !== receipt.size || identity.sha256 !== receipt.sha256) throw Error("Source file changed; refusing to resume");
3970
+ } else {
3971
+ const file = resolve8(input), identity = await fingerprint(file, signal);
3972
+ receipt = { version: 1, file, ...identity, state: "creating" };
3973
+ await writeFile(path, JSON.stringify(receipt, null, 2) + "\n", { flag: "wx", mode: 384 });
3974
+ show("Upload opening", { receipt: path, file, bytes: identity.size });
3975
+ signal?.throwIfAborted();
3976
+ const plan = await client.request("/v1/uploads/create", { method: "POST", body: JSON.stringify({ filename: basename4(file), size: identity.size, content_type: "application/octet-stream" }), ...signal ? { signal } : {} });
3977
+ receipt = { ...receipt, upload_id: plan.upload_id, key: plan.key, part_size: plan.part_size, part_count: plan.part_count, state: "uploading" };
3978
+ try {
3979
+ await save(path, receipt);
3980
+ } catch (error) {
3981
+ show("Upload receipt write failed", { receipt: path, upload_id: receipt.upload_id, key: receipt.key, action: "Keep these identifiers; creation must not be repeated." });
3982
+ throw error;
3983
+ }
3984
+ }
3985
+ if (!receipt.upload_id || !receipt.key || !Number.isInteger(receipt.part_size) || receipt.part_size < 1 || !Number.isInteger(receipt.part_count) || receipt.part_count < 1 || receipt.part_count > 1e4 || Math.ceil(receipt.size / receipt.part_size) !== receipt.part_count) throw Error("Invalid multipart upload plan; receipt preserved");
3986
+ const base = `/v1/uploads/${encodeURIComponent(receipt.upload_id)}`;
3987
+ const state = await client.request(`${base}/status?${new URLSearchParams({ key: receipt.key })}`, signal ? { signal } : void 0);
3988
+ const parts = [...state.uploaded_parts];
3989
+ const stored = new Set(parts.map((p) => p.part_number));
3990
+ const handle = await open3(receipt.file, "r");
3991
+ try {
3992
+ for (let n = 1; n <= receipt.part_count; n++) {
3993
+ signal?.throwIfAborted();
3994
+ if (stored.has(n)) continue;
3995
+ const signed = await client.request(base + "/sign", { method: "POST", body: JSON.stringify({ key: receipt.key, part_numbers: [n] }), ...signal ? { signal } : {} });
3996
+ signal?.throwIfAborted();
3997
+ const offset2 = (n - 1) * receipt.part_size, length = Math.min(receipt.part_size, receipt.size - offset2), bytes = Buffer.alloc(length);
3998
+ const read2 = await handle.read(bytes, 0, length, offset2);
3999
+ if (read2.bytesRead !== length) throw Error("Source file changed while uploading");
4000
+ const response = await fetch(transferUrl(signed.urls[String(n)]), { method: "PUT", body: bytes, redirect: "error", signal });
4001
+ if (!response.ok) throw Error(`Upload part ${n} failed (${response.status}); resume with /uploads retry '${path}' --confirm`);
4002
+ const etag = response.headers.get("etag");
4003
+ if (!etag) throw Error(`Upload part ${n} returned no ETag; resume from server status`);
4004
+ parts.push({ part_number: n, etag });
4005
+ show("Upload progress", { receipt: path, uploaded_parts: parts.length, total_parts: receipt.part_count });
4006
+ }
4007
+ } finally {
4008
+ await handle.close();
4009
+ }
4010
+ const finalIdentity = await fingerprint(receipt.file, signal);
4011
+ if (finalIdentity.size !== receipt.size || finalIdentity.sha256 !== receipt.sha256) throw Error("Source file changed while uploading; refusing completion; receipt preserved");
4012
+ signal?.throwIfAborted();
4013
+ receipt.state = "completing";
4014
+ await save(path, receipt);
4015
+ const result = await client.request(base + "/complete", { method: "POST", body: JSON.stringify({ key: receipt.key, parts: parts.map(({ part_number, etag }) => ({ part_number, etag })).sort((a, b) => a.part_number - b.part_number) }), ...signal ? { signal } : {} });
4016
+ receipt.state = "complete";
4017
+ await save(path, receipt);
4018
+ show("Upload complete", { receipt: path, result });
4019
+ }
4020
+
4021
+ // src/hosted/dashboard.ts
4022
+ var enc = encodeURIComponent;
4023
+ var dashboardHelpLines = [
4024
+ "/dashboard help \u2014 commands use shell-quoted arguments; writes require repeating with --confirm",
4025
+ "/pipelines list | show ID | status ID | task [TASK]",
4026
+ "/runs list [--app-name NAME --limit N] | show ID [--logs true|false] | claims",
4027
+ "/runs sync --confirm | explain ID [--regenerate true|false] --confirm (paid log explanation)",
4028
+ "/sources list | check ALIAS --confirm",
4029
+ "/sources objects ALIAS --limit N [--prefix PREFIX]",
4030
+ "/sources connect ALIAS --config FILE --confirm | disconnect ALIAS --confirm",
4031
+ "/datasets list [--task TASK]; /evals list [--limit N --after CURSOR --pipeline-id ID] | show ID | report ID",
4032
+ "/evals results ID --model-run-id ID [--limit N --after CURSOR]",
4033
+ "/benchmarks list [--task TASK --benchmark NAME --status STATUS --limit N --offset N] | show EXPERIMENT",
4034
+ "/benchmarks events TRAINING_RUN [--limit N --after CURSOR --kind KIND,KIND --query TEXT --problems-only true|false]",
4035
+ "/experiments list [--task TASK] | show ID | comparison ID | estimate ID",
4036
+ "/models list; /monitoring summary MODEL | series MODEL | drift MODEL [--days N]",
4037
+ "/monitoring compare BASELINE CANDIDATE [--days N]",
4038
+ "/interactions list [--task TASK --model-id ID --feedback-status STATUS --limit N --offset N] | show ID",
4039
+ "/uploads list | status ID --key KEY | cancel ID --key KEY --confirm",
4040
+ "/uploads send FILE --receipt FILE --confirm | retry RECEIPT --confirm (server parts are resume authority)",
4041
+ "/artifacts list [--kind KIND --limit N --has-weights true|false] | records [--kind KIND] | show ID",
4042
+ "/artifacts download VOLUME PATH --output FILE [--format resume|adapter] --confirm",
4043
+ "/artifacts download-record ID --output FILE --confirm (never overwrites)",
4044
+ "/serving list | pools | show ID",
4045
+ "/serving promote EXPERIMENT CANDIDATE [--alias production] --confirm",
4046
+ '/serving sample "PROMPT" [--task TASK --max-tokens N --temperature N --conversation FILE] --confirm',
4047
+ "/sdk [TOPIC|index|all] \u2014 read the shared SDK guide; index lists browser section names"
4048
+ ];
4049
+ function argumentsFor(tokens) {
4050
+ const values = [];
4051
+ const options = {};
4052
+ let confirm = false;
4053
+ for (let i = 0; i < tokens.length; i++) {
4054
+ const token = tokens[i];
4055
+ if (token === "--confirm") {
4056
+ if (confirm) throw Error("Duplicate --confirm");
4057
+ confirm = true;
4058
+ } else if (token.startsWith("--")) {
4059
+ const key2 = token.slice(2);
4060
+ const value = tokens[++i];
4061
+ if (!key2 || value === void 0 || value.startsWith("--") || Object.hasOwn(options, key2)) throw Error(`Expected one value for ${token}`);
4062
+ options[key2] = value;
4063
+ } else values.push(token);
4064
+ }
4065
+ return { values, options, confirm };
4066
+ }
4067
+ function validate(a, min, max, allowed = []) {
4068
+ if (a.values.length < min || a.values.length > max) throw Error(`Expected ${min === max ? min : `${min}\u2013${max}`} positional arguments; see /dashboard help`);
4069
+ for (const key2 of Object.keys(a.options)) if (!allowed.includes(key2)) throw Error(`Unknown option --${key2}`);
4070
+ }
4071
+ function required(a, key2) {
4072
+ const value = a.options[key2];
4073
+ if (!value) throw Error(`Required --${key2}`);
4074
+ return value;
4075
+ }
4076
+ function task(a, context) {
4077
+ const value = a.options.task ?? context.task;
4078
+ if (!value) throw Error("Select a task or pass --task TASK");
4079
+ return value;
4080
+ }
4081
+ function query(path, a, keys, defaults = {}) {
4082
+ const params = new URLSearchParams();
4083
+ for (const key2 of keys) if (a.options[key2] !== void 0) params.set(key2.replaceAll("-", "_"), a.options[key2]);
4084
+ for (const [key2, value] of Object.entries(defaults)) if (!params.has(key2)) params.set(key2, value);
4085
+ return path + (params.size ? `?${params}` : "");
4086
+ }
4087
+ function clean(value) {
4088
+ if (typeof value === "string") return value.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
4089
+ if (Array.isArray(value)) return value.map(clean);
4090
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [String(clean(k)), clean(v)]));
4091
+ return value;
4092
+ }
4093
+ function confirmation(context, a, title, target) {
4094
+ if (a.confirm) return true;
4095
+ context.show("Confirmation required", clean({ action: title, target, instruction: "Repeat the exact command with --confirm. No write was sent." }));
4096
+ return false;
4097
+ }
4098
+ function currentBenchmarkStatus(row) {
4099
+ const candidates = Array.isArray(row.candidates) ? row.candidates : [];
4100
+ const base = candidates.find((candidate) => candidate.recipe === "external_ptb" && candidate.config?.ptb_role === "base");
4101
+ const runs = Array.isArray(base?.runs) ? base.runs : [];
4102
+ const stamp = (run) => {
4103
+ const value = Date.parse(String(run.started_at ?? run.created_at ?? ""));
4104
+ return Number.isNaN(value) ? -Infinity : value;
4105
+ };
4106
+ const latest = [...runs].sort((a, b) => stamp(a) - stamp(b)).at(-1);
4107
+ return latest?.status ?? null;
4108
+ }
4109
+ async function runDashboardCommand(input, context) {
4110
+ const family = input.trim().split(/\s/)[0]?.slice(1);
4111
+ if (!input.trim().startsWith("/") || !["dashboard", "pipelines", "runs", "sources", "datasets", "evals", "benchmarks", "experiments", "models", "monitoring", "interactions", "uploads", "artifacts", "serving", "sdk"].includes(family ?? "")) return false;
4112
+ const sensitiveValues = [];
4113
+ const redact = (value) => {
4114
+ if (typeof value === "string") return sensitiveValues.reduce((text, secret) => text.split(secret).join("[credential]"), value);
4115
+ if (Array.isArray(value)) return value.map(redact);
4116
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key2, item]) => [key2, redact(item)]));
4117
+ return value;
4118
+ };
4119
+ const show = (title, value) => context.show(title, clean(redact(value)));
4120
+ const client = { request: (path, init) => context.client.request(path, context.signal ? { ...init, signal: context.signal } : init) };
4121
+ try {
4122
+ const parsed = parse4(input, () => {
4123
+ throw Error("Environment expansion is unavailable; use literal quoted values");
4124
+ });
4125
+ if (parsed.some((token) => typeof token !== "string")) throw Error("Shell operators and comments are unavailable");
4126
+ const tokens = parsed;
4127
+ tokens.shift();
4128
+ const action = tokens.shift() ?? (family === "sdk" ? "" : "list");
4129
+ const a = argumentsFor(tokens);
4130
+ const v = a.values;
4131
+ if (family === "dashboard" || action === "help") {
4132
+ show("Hosted dashboard commands", dashboardHelpLines);
4133
+ return true;
4134
+ }
4135
+ if (family === "sdk") {
4136
+ validate(a, 0, 0);
4137
+ const topic = action;
4138
+ const reference = sdkReference(topic);
4139
+ context.show(reference.title, reference.content);
4140
+ return true;
4141
+ }
4142
+ if (family === "benchmarks") {
4143
+ if (action === "show") {
4144
+ validate(a, 1, 1);
4145
+ show("Benchmark experiment", await client.request(`/v1/experiments/${enc(v[0])}`));
4146
+ return true;
4147
+ }
4148
+ if (action === "events") {
4149
+ validate(a, 1, 1, ["limit", "after", "kind", "query", "problems-only"]);
4150
+ const limit2 = Number(a.options.limit ?? 200);
4151
+ if (!Number.isInteger(limit2) || limit2 < 1 || limit2 > 500) throw Error("Event limit must be 1\u2013500");
4152
+ if (a.options["problems-only"] && !["true", "false"].includes(a.options["problems-only"])) throw Error("problems-only must be true or false");
4153
+ const path = query(`/v1/training-runs/${enc(v[0])}`, a, ["after", "limit"], { limit: String(limit2) });
4154
+ const page = await client.request(path);
4155
+ const events = page.events ?? [], kinds = a.options.kind ? new Set(a.options.kind.split(",")) : void 0, needle = (a.options.query ?? "").trim().toLowerCase();
4156
+ const filtered2 = events.filter((event) => (!kinds || kinds.has(String(event.event))) && (a.options["problems-only"] !== "true" || ["warning", "warn", "error", "critical"].includes(String(event.level ?? "").toLowerCase()) || ["integrity_flag", "stopped"].includes(String(event.event))) && (!needle || `${event.event} ${event.message ?? ""} ${JSON.stringify(Object.fromEntries(Object.entries(event.metadata ?? {}).filter(([key2]) => key2 !== "event_key")))}`.toLowerCase().includes(needle))).reverse();
4157
+ show("Benchmark event page", { ...page, events: filtered2, page_event_count: events.length, filtered_event_count: filtered2.length, filters_apply_to: "this event page; use next_after to read the next page" });
4158
+ return true;
4159
+ }
4160
+ if (action !== "list") throw Error("Use /benchmarks list, show or events");
4161
+ validate(a, 0, 0, ["task", "benchmark", "status", "limit", "offset"]);
4162
+ const rows = await client.request("/v1/experiments");
4163
+ const selectedTask2 = a.options.task ?? context.task;
4164
+ const normalize = (t) => String(t ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
4165
+ const filtered = rows.filter((row) => {
4166
+ const cfg = row.eval_config;
4167
+ return cfg?.kind === "ptb_benchmark_run" && (!selectedTask2 || normalize(row.task) === normalize(selectedTask2)) && (!a.options.benchmark || cfg.benchmark === a.options.benchmark) && (!a.options.status || currentBenchmarkStatus(row) === a.options.status);
4168
+ });
4169
+ const limit = Number(a.options.limit ?? 25), offset2 = Number(a.options.offset ?? 0);
4170
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200 || !Number.isInteger(offset2) || offset2 < 0) throw Error("limit must be 1\u2013200; offset must be nonnegative");
4171
+ show("Benchmark runs", { items: filtered.slice(offset2, offset2 + limit), total: filtered.length, limit, offset: offset2, next_offset: offset2 + limit < filtered.length ? offset2 + limit : null, pagination: "client-side; backend experiment list is not paginated" });
4172
+ return true;
4173
+ }
4174
+ if (family === "uploads" && ["send", "retry"].includes(action)) {
4175
+ validate(a, 1, 1, action === "send" ? ["receipt"] : []);
4176
+ if (action === "send") required(a, "receipt");
4177
+ if (confirmation(context, a, `Upload ${action}`, { file: v[0], receipt: a.options.receipt })) await uploadFile(action, v[0], a.options.receipt, client, show, context.signal);
4178
+ return true;
4179
+ }
4180
+ if (family === "artifacts" && ["download", "download-record"].includes(action)) {
4181
+ validate(a, action === "download" ? 2 : 1, action === "download" ? 2 : 1, action === "download" ? ["output", "format"] : ["output"]);
4182
+ required(a, "output");
4183
+ const format = a.options.format ?? "resume";
4184
+ if (!["resume", "adapter"].includes(format)) throw Error("format must be resume or adapter");
4185
+ if (confirmation(context, a, "Download artifact", { source: v, output: a.options.output, format })) {
4186
+ const path = action === "download" ? "/v1/modal-artifacts/download-url" : `/v1/artifacts/${enc(v[0])}/access-url`;
4187
+ const body = action === "download" ? { volume: v[0], path: v[1], format } : { expires_in: 300 };
4188
+ await downloadArtifact(client, path, body, a.options.output, show, context.signal);
4189
+ }
4190
+ return true;
4191
+ }
4192
+ if (family === "serving" && action === "sample") {
4193
+ validate(a, 1, 1, ["task", "max-tokens", "temperature", "conversation"]);
4194
+ const model = task(a, context);
4195
+ const maxTokens = Number(a.options["max-tokens"] ?? 256), temperature = Number(a.options.temperature ?? 0.7);
4196
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 8192 || !Number.isFinite(temperature) || temperature < 0 || temperature > 2) throw Error("max-tokens must be 1\u20138192; temperature must be 0\u20132");
4197
+ if (!confirmation(context, a, "Sample model (inference spend)", { task: model, prompt: v[0], max_tokens: maxTokens, temperature, conversation: a.options.conversation })) return true;
4198
+ await sampleModel(context, model, v[0], maxTokens, temperature, a.options.conversation, show);
4199
+ return true;
4200
+ }
4201
+ let op;
4202
+ const read2 = (path, min = 0, max = min, opts = []) => {
4203
+ validate(a, min, max, opts);
4204
+ op = { path };
4205
+ };
4206
+ const write = (path, method, body, min, max = min, opts = []) => {
4207
+ validate(a, min, max, opts);
4208
+ op = { path, method, body };
4209
+ };
4210
+ if (family === "pipelines") {
4211
+ if (action === "list") read2("/v1/pipelines");
4212
+ if (action === "show" || action === "status") read2(`/v1/pipelines/${enc(v[0] ?? "")}${action === "status" ? "/status" : ""}`, 1);
4213
+ if (action === "task") {
4214
+ validate(a, 0, 1);
4215
+ const selected = v[0] ?? context.task;
4216
+ if (!selected) throw Error("Select a task or provide TASK");
4217
+ op = { path: `/v1/tasks/${enc(selected)}/pipeline` };
4218
+ }
4219
+ } else if (family === "runs") {
4220
+ if (action === "list") read2(query("/v1/modal-runs", a, ["app-name", "limit"]), 0, 0, ["app-name", "limit"]);
4221
+ if (action === "show") read2(query(`/v1/modal-runs/${enc(v[0] ?? "")}`, a, ["logs"]), 1, 1, ["logs"]);
4222
+ if (action === "claims") read2("/v1/modal-run-claims");
4223
+ if (action === "sync") write("/v1/modal-runs/sync", "POST", void 0, 0);
4224
+ if (action === "explain") write(query(`/v1/modal-runs/${enc(v[0] ?? "")}/explain`, a, ["regenerate"]), "POST", void 0, 1, 1, ["regenerate"]);
4225
+ } else if (family === "sources") {
4226
+ if (action === "list") read2("/v1/pipeline-data-sources");
4227
+ if (action === "objects") {
4228
+ required(a, "limit");
4229
+ read2(query(`/v1/pipeline-data-sources/${enc(v[0] ?? "")}/objects`, a, ["limit", "prefix"]), 1, 1, ["limit", "prefix"]);
4230
+ }
4231
+ if (action === "check") write(`/v1/pipeline-data-sources/${enc(v[0] ?? "")}/check`, "POST", void 0, 1);
4232
+ if (action === "disconnect") write(`/v1/pipeline-data-sources/${enc(v[0] ?? "")}`, "DELETE", void 0, 1);
4233
+ if (action === "connect") {
4234
+ validate(a, 1, 1, ["config"]);
4235
+ const config = required(a, "config");
4236
+ if (!confirmation(context, a, "Connect data source", { alias: v[0], config })) return true;
4237
+ let body;
4238
+ try {
4239
+ body = JSON.parse(await readFile4(config, "utf8"));
4240
+ } catch {
4241
+ throw Error("Source config must be a readable JSON object");
4242
+ }
4243
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw Error("Source config must be a JSON object");
4244
+ if (body.credentials && typeof body.credentials === "object") {
4245
+ for (const value of Object.values(body.credentials)) if (typeof value === "string" && value) sensitiveValues.push(value);
4246
+ }
4247
+ if (typeof body.credential_ref === "string" && body.credential_ref) sensitiveValues.push(body.credential_ref);
4248
+ op = { path: `/v1/pipeline-data-sources/${enc(v[0])}`, method: "PUT", body };
4249
+ }
4250
+ } else if (family === "datasets" || family === "models") {
4251
+ if (action === "list") read2(`/v1/${family}`, 0, 0, family === "datasets" ? ["task"] : []);
4252
+ } else if (family === "evals") {
4253
+ const path = `/v1/evaluation-runs/${enc(v[0] ?? "")}`;
4254
+ if (action === "list") read2(query("/v1/evaluation-runs", a, ["limit", "after"]), 0, 0, ["limit", "after", "pipeline-id"]);
4255
+ if (action === "show" || action === "report") read2(path + (action === "report" ? "/report" : ""), 1);
4256
+ if (action === "results") {
4257
+ required(a, "model-run-id");
4258
+ read2(query(`${path}/task-results`, a, ["model-run-id", "after", "limit"]), 1, 1, ["model-run-id", "after", "limit"]);
4259
+ }
4260
+ } else if (family === "experiments") {
4261
+ if (action === "list") read2("/v1/experiments", 0, 0, ["task"]);
4262
+ if (["show", "comparison", "estimate"].includes(action)) read2(`/v1/experiments/${enc(v[0] ?? "")}${action === "show" ? "" : "/" + action}`, 1);
4263
+ } else if (family === "monitoring") {
4264
+ if (["summary", "series", "drift"].includes(action)) read2(query(`/v1/models/${enc(v[0] ?? "")}/monitoring${action === "summary" ? "" : action === "series" ? "/timeseries" : "/drift"}`, a, ["days", "minimum-reviewed"]), 1, 1, action === "drift" ? ["days", "minimum-reviewed"] : ["days"]);
4265
+ if (action === "compare") read2(query("/v1/monitoring/compare", a, ["days"], { baseline_model_id: v[0] ?? "", candidate_model_id: v[1] ?? "" }), 2, 2, ["days"]);
4266
+ } else if (family === "interactions") {
4267
+ if (action === "list") read2(query("/v1/interactions", a, ["limit", "offset", "feedback-status", "model-id", "task"], context.task ? { task: context.task } : {}), 0, 0, ["task", "model-id", "feedback-status", "limit", "offset"]);
4268
+ if (action === "show") read2(`/v1/interactions/${enc(v[0] ?? "")}`, 1);
4269
+ } else if (family === "uploads") {
4270
+ if (action === "list") read2("/v1/uploads");
4271
+ if (action === "status" || action === "cancel") {
4272
+ required(a, "key");
4273
+ validate(a, 1, 1, ["key"]);
4274
+ op = { path: query(`/v1/uploads/${enc(v[0])}/${action === "status" ? "status" : "abort"}`, a, ["key"]), method: action === "cancel" ? "POST" : void 0 };
4275
+ }
4276
+ } else if (family === "artifacts") {
4277
+ if (action === "list") read2(query("/v1/modal-artifacts", a, ["kind", "limit"]), 0, 0, ["kind", "limit", "has-weights"]);
4278
+ if (action === "records") read2(query("/v1/artifacts", a, ["kind"]), 0, 0, ["kind"]);
4279
+ if (action === "show") read2(`/v1/artifacts/${enc(v[0] ?? "")}`, 1);
4280
+ } else if (family === "serving") {
4281
+ if (action === "list") read2("/v1/deployments");
4282
+ if (action === "pools") read2("/v1/serving/pools");
4283
+ if (action === "show") read2(`/v1/deployments/${enc(v[0] ?? "")}`, 1);
4284
+ if (action === "promote") write(`/v1/experiments/${enc(v[0] ?? "")}/promote`, "POST", { candidate_id: v[1], alias: a.options.alias ?? "production" }, 2, 2, ["alias"]);
4285
+ }
4286
+ if (!op) throw Error(`Unknown ${family} action '${action}'; see /dashboard help`);
4287
+ if (op.method && !confirmation(context, a, `${family} ${action}`, { method: op.method, path: op.path, ...family === "sources" && action === "connect" ? { config: a.options.config } : { body: op.body, ...a.options.output ? { output: a.options.output } : {} } })) return true;
4288
+ const init = op.method ? { method: op.method, ...op.body === void 0 ? {} : { body: JSON.stringify(op.body) } } : void 0;
4289
+ let result = await client.request(op.path, init);
4290
+ if (family === "evals" && action === "list" && a.options["pipeline-id"]) {
4291
+ const page = result;
4292
+ if (Array.isArray(page.items)) result = { ...page, items: page.items.filter((row) => row.pipeline_id === a.options["pipeline-id"]), filters_apply_to: "this page; use next_cursor for the next page" };
4293
+ }
4294
+ if (family === "artifacts" && action === "list" && a.options["has-weights"]) {
4295
+ if (!["true", "false"].includes(a.options["has-weights"])) throw Error("has-weights must be true or false");
4296
+ const page = result;
4297
+ if (Array.isArray(page.artifacts)) result = { ...page, artifacts: page.artifacts.filter((row) => row.has_weights === (a.options["has-weights"] === "true")) };
4298
+ }
4299
+ const selectedTask = a.options.task ?? context.task;
4300
+ if (selectedTask && action === "list" && ["datasets", "experiments"].includes(family) && Array.isArray(result)) result = result.filter((row) => row?.task === selectedTask);
4301
+ show(`${family} ${action}`, result);
4302
+ } catch (error) {
4303
+ show("Dashboard command failed", error instanceof Error ? error.message : String(error));
4304
+ }
4305
+ return true;
4306
+ }
4307
+
4308
+ // src/hosted/entry.ts
4309
+ var DEFAULT_DASHBOARD_URL = "https://manifold.ashr.io";
4310
+ var accountHelp = [
4311
+ "/account who this terminal is signed in as",
4312
+ "/org open organization settings in the dashboard",
4313
+ "/login sign in again, e.g. to switch organization",
4314
+ "/logout sign this terminal out"
4315
+ ];
4316
+ function openBrowser(url) {
4317
+ const program2 = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
4318
+ const child = spawn(program2, [url], { stdio: "ignore", detached: true });
4319
+ child.unref();
4320
+ return new Promise((resolve10, reject) => {
4321
+ child.once("error", reject);
4322
+ child.once("exit", (code) => code === 0 ? resolve10() : reject(Error("Browser could not open")));
4323
+ });
4324
+ }
4325
+ var color = (on) => {
4326
+ const wrap = (code) => (text) => on ? `\x1B[${code}m${text}\x1B[0m` : text;
4327
+ return { bold: wrap("1"), dim: wrap("2"), accent: wrap("38;2;134;122;254"), green: wrap("32"), red: wrap("31") };
4328
+ };
4329
+ async function signIn(apiUrl, dashboardUrl, credentials, options, reason) {
4330
+ const write = options.write ?? ((text) => process.stdout.write(text));
4331
+ const tty = options.interactive ?? !!process.stdout.isTTY;
4332
+ const c = color(tty);
4333
+ const abort = new AbortController();
4334
+ const onInterrupt = () => abort.abort();
4335
+ process.once("SIGINT", onInterrupt);
4336
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4337
+ let spinner;
4338
+ try {
4339
+ write(`
4340
+ ${c.bold("ashr")} ${c.dim("\xB7 Manifold")}
4341
+
4342
+ `);
4343
+ if (reason) write(` ${reason}
4344
+
4345
+ `);
4346
+ const login2 = await (options.startLogin ?? startTerminalLogin)({
4347
+ dashboardUrl,
4348
+ device: hostname().replace(/\.local$/, ""),
4349
+ signal: abort.signal,
4350
+ onUrl: (url) => {
4351
+ write(` Sign in to continue. Opening your browser\u2026
4352
+ ${c.dim("If it doesn't open, visit:")}
4353
+ ${c.accent(url)}
4354
+
4355
+ `);
4356
+ void (options.openBrowser ?? openBrowser)(url).catch(() => {
4357
+ });
4358
+ if (tty) {
4359
+ let frame = 0;
4360
+ const line2 = () => `\r ${c.accent(frames[frame++ % frames.length])} Waiting for you to approve in the browser ${c.dim("\xB7 Ctrl+C to cancel")}`;
4361
+ write(line2());
4362
+ spinner = setInterval(() => write(line2()), 80);
4363
+ } else write(" Waiting for you to approve in the browser\u2026\n");
4364
+ }
4365
+ });
4366
+ if (spinner) {
4367
+ clearInterval(spinner);
4368
+ spinner = void 0;
4369
+ write("\r\x1B[2K");
4370
+ }
4371
+ await (options.validate ?? validateKey)(apiUrl, login2.key, options.fetch);
4372
+ const saved = { apiUrl, key: login2.key, profile: credentials.manifold?.profile ?? "default", email: login2.email, organization: login2.organization };
4373
+ (options.saveCredentials ?? writeCredentials)({ ...credentials, manifold: saved });
4374
+ const who = [login2.email, login2.organization].filter(Boolean).map((v) => terminalText(v)).join(c.dim(" \xB7 "));
4375
+ write(` ${c.green("\u2713")} Signed in${who ? ` as ${c.bold(who)}` : ""}
4376
+
4377
+ `);
4378
+ return saved;
4379
+ } catch (error) {
4380
+ if (spinner) {
4381
+ clearInterval(spinner);
4382
+ write("\r\x1B[2K");
4383
+ }
4384
+ if (abort.signal.aborted) {
4385
+ write(` Sign-in cancelled.
4386
+ `);
4387
+ return void 0;
4388
+ }
4389
+ write(` ${c.red("\u2717")} ${terminalText(error.message)}
4390
+ `);
4391
+ throw error;
4392
+ } finally {
4393
+ process.removeListener("SIGINT", onInterrupt);
4394
+ }
4395
+ }
4396
+ async function runLogin(args, credentials, options = {}) {
4397
+ const env = options.env ?? process.env;
4398
+ const apiUrl = args.apiUrl ?? env.MANIFOLD_API_URL ?? DEFAULT_API_URL;
4399
+ const dashboardUrl = args.dashboardUrl ?? env.MANIFOLD_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL;
4400
+ return await signIn(apiUrl, dashboardUrl, credentials, options) ? 0 : 130;
4401
+ }
4402
+ async function runHostedEntry(args, credentials, options = {}) {
4403
+ const env = options.env ?? process.env;
4404
+ const write = options.write ?? ((text) => process.stdout.write(text));
4405
+ const apiUrl = args.apiUrl ?? env.MANIFOLD_API_URL ?? credentials.manifold?.apiUrl ?? DEFAULT_API_URL;
4406
+ const dashboardUrl = args.dashboardUrl ?? env.MANIFOLD_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL;
4407
+ const explicit = args.key ?? env.MANIFOLD_API_KEY;
4408
+ const save2 = options.saveCredentials ?? writeCredentials;
4409
+ let saved = !explicit && credentials.manifold && credentials.manifold.apiUrl === apiUrl ? credentials.manifold : void 0;
4410
+ let token = explicit;
4411
+ if (!token && args.service) throw Error("No Manifold service key. Set MANIFOLD_API_KEY or run ashr login --key <key>.");
4412
+ if (!token && saved) {
4413
+ try {
4414
+ await (options.validate ?? validateKey)(apiUrl, saved.key, options.fetch);
4415
+ token = saved.key;
4416
+ } catch (error) {
4417
+ if (!(error instanceof KeyRejectedError && error.status === 401)) throw error;
4418
+ const { manifold: _revoked, ...rest } = credentials;
4419
+ credentials = rest;
4420
+ save2(credentials);
4421
+ saved = await signIn(apiUrl, dashboardUrl, credentials, options, "This terminal's sign-in was revoked. Sign in again.");
4422
+ if (!saved) return 130;
4423
+ credentials = { ...credentials, manifold: saved };
4424
+ token = saved.key;
4425
+ }
4426
+ }
4427
+ if (!token) {
4428
+ saved = await signIn(apiUrl, dashboardUrl, credentials, options);
4429
+ if (!saved) return 130;
4430
+ credentials = { ...credentials, manifold: saved };
4431
+ token = saved.key;
4432
+ }
4433
+ const current = { token };
4434
+ const client = new ManifoldClient({ apiUrl, getToken: () => Promise.resolve(current.token), fetch: options.fetch });
4435
+ let task2 = args.task, chat = args.chat;
4436
+ for (; ; ) {
4437
+ let handle;
4438
+ let next;
4439
+ const result = await (options.run ?? runHostedApp)({
4440
+ client,
4441
+ terminal: options.terminal ?? new ProcessTerminal(),
4442
+ task: task2,
4443
+ chat,
4444
+ env,
4445
+ onReady: (value) => {
4446
+ handle = value;
4447
+ },
4448
+ runDashboardCommand: async (input, context) => {
4449
+ if (input === "/help") {
4450
+ context.show("Dashboard commands", dashboardHelpLines.join("\n"));
4451
+ context.show("Account", accountHelp.join("\n"));
4452
+ return false;
4453
+ }
4454
+ if (input === "/login" || input === "/logout") {
4455
+ if (explicit) throw Error("This session uses MANIFOLD_API_KEY or --key; unset it to sign in through the browser.");
4456
+ next = input === "/login" ? "login" : "logout";
4457
+ handle?.quit();
4458
+ return true;
4459
+ }
4460
+ if (input === "/account") {
4461
+ context.show("Account", saved ? [`Signed in as ${saved.email ?? "unknown account"}`, `Organization: ${saved.organization ?? "\u2014"}`, `API: ${apiUrl}`, `Key: ${saved.key.slice(0, 12)}\u2026`].join("\n") : `Using a service key (${current.token.slice(0, 12)}\u2026) against ${apiUrl}.`);
4462
+ return true;
4463
+ }
4464
+ if (input === "/account settings" || /^\/org(?:\s|$)/.test(input)) {
4465
+ const url = new URL("/?view=organization", dashboardUrl).toString();
4466
+ void (options.openBrowser ?? openBrowser)(url).catch(() => {
4467
+ });
4468
+ context.show("Opened in your browser", url);
4469
+ return true;
4470
+ }
4471
+ return runDashboardCommand(input, context);
4472
+ }
4473
+ });
4474
+ if (!next) return result;
4475
+ const { manifold: _signedOut, ...rest } = credentials;
4476
+ credentials = rest;
4477
+ save2(credentials);
4478
+ if (next === "logout") {
4479
+ write("Signed out. The terminal key stays listed under API keys in Manifold until an admin revokes it.\n");
4480
+ return 0;
4481
+ }
4482
+ saved = await signIn(apiUrl, dashboardUrl, credentials, options);
4483
+ if (!saved) return 130;
4484
+ credentials = { ...credentials, manifold: saved };
4485
+ current.token = saved.key;
4486
+ task2 = void 0;
4487
+ chat = void 0;
4488
+ }
4489
+ }
4490
+
4491
+ // src/main.ts
4492
+ function findRepoRoot(cwd) {
4493
+ let dir = resolve9(cwd);
4494
+ for (; ; ) {
4495
+ if (existsSync7(join9(dir, ".git"))) return dir;
4496
+ const up = dirname5(dir);
4497
+ if (up === dir) return resolve9(cwd);
4498
+ dir = up;
4499
+ }
4500
+ }
4501
+ function hiddenPrompt(question) {
4502
+ return new Promise((done, fail) => {
4503
+ const stdin = process.stdin;
4504
+ if (!stdin.isTTY) {
4505
+ fail(new Error("No terminal to read the key from. Pass it with --key."));
4506
+ return;
4507
+ }
4508
+ process.stdout.write(question);
4509
+ let value = "";
4510
+ stdin.setRawMode(true);
4511
+ stdin.resume();
4512
+ const onData = (buf) => {
4513
+ for (const ch of buf.toString("utf8")) {
4514
+ if (ch === "\r" || ch === "\n") {
4515
+ stdin.setRawMode(false);
4516
+ stdin.pause();
4517
+ stdin.off("data", onData);
4518
+ process.stdout.write("\n");
4519
+ done(value.trim());
4520
+ return;
4521
+ }
4522
+ if (ch === "") {
4523
+ stdin.setRawMode(false);
4524
+ process.stdout.write("\n");
4525
+ process.exit(130);
4526
+ }
4527
+ if (ch === "\x7F") value = value.slice(0, -1);
4528
+ else value += ch;
4529
+ }
4530
+ };
4531
+ stdin.on("data", onData);
4532
+ });
4533
+ }
4534
+ async function login(args) {
4535
+ const creds = readCredentials();
4536
+ if (args.provider) {
4537
+ const names = PROVIDER_KEYS[args.provider];
4538
+ if (!names) throw new UsageError(`Unknown provider "${args.provider}". Known: ${Object.keys(PROVIDER_KEYS).join(", ")}.`);
4539
+ const key3 = args.key ?? await hiddenPrompt(`${args.provider} API key: `);
4540
+ if (!key3) throw new Error("No key entered.");
4541
+ creds.providers[args.provider] = key3;
4542
+ writeCredentials(creds);
4543
+ process.stdout.write(`Saved your ${args.provider} key. ashr uses it when ${names[0]} is not set in your shell.
4544
+ `);
4545
+ return 0;
4546
+ }
4547
+ if (!args.key && !args.service) return runLogin(args, creds);
4548
+ const apiUrl = args.apiUrl ?? DEFAULT_API_URL;
4549
+ const key2 = args.key ?? await hiddenPrompt("Manifold API key (ask_\u2026): ");
4550
+ if (!key2) throw new Error("No key entered.");
4551
+ await validateKey(apiUrl, key2);
4552
+ writeCredentials({ ...creds, manifold: { apiUrl, key: key2, profile: creds.manifold?.profile ?? "default" } });
4553
+ process.stdout.write(`Signed in to ${apiUrl}.
4554
+ `);
4555
+ return 0;
4556
+ }
4557
+ function logout(args) {
4558
+ const creds = readCredentials();
4559
+ if (args.provider) {
4560
+ delete creds.providers[args.provider];
4561
+ process.stdout.write(`Removed the stored ${args.provider} key.
4562
+ `);
4563
+ } else {
4564
+ delete creds.manifold;
4565
+ process.stdout.write("Signed out of Manifold.\n");
4566
+ }
4567
+ writeCredentials(creds);
4568
+ return 0;
4569
+ }
4570
+ async function main(argv) {
4571
+ const args = parseArgs(argv);
4572
+ if (args.command === "help") {
4573
+ process.stdout.write(`ashr ${package_default.version} \u2014 the Ashr terminal agent
4574
+
4575
+ ${USAGE}
4576
+ `);
4577
+ return 0;
4578
+ }
4579
+ if (args.command === "version") {
4580
+ process.stdout.write(`${package_default.version}
4581
+ `);
4582
+ return 0;
4583
+ }
4584
+ if (args.command === "login") return login(args);
4585
+ if (args.command === "logout") return logout(args);
4586
+ const creds = readCredentials();
4587
+ if (args.runtime === "hosted") return runHostedEntry(args, creds);
4588
+ applyProviderKeys(creds, process.env);
4589
+ const repoRoot = findRepoRoot(process.cwd());
4590
+ const org = creds.manifold?.profile ?? "default";
4591
+ if (args.print !== void 0) {
4592
+ return runPrint({ repoRoot, prompt: args.print, mode: args.mode, allow: args.allow, output: args.output, env: process.env, model: args.model, org });
4593
+ }
4594
+ return runApp({ repoRoot, terminal: new ProcessTerminal2(), env: process.env, org, model: args.model, continueLatest: args.continue, resumePicker: args.resume });
4595
+ }
4596
+ main(process.argv.slice(2)).then(
4597
+ (code) => process.exit(code),
4598
+ (err) => {
4599
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
4600
+ `);
4601
+ process.exit(err instanceof UsageError ? 2 : 1);
4602
+ }
4603
+ );
4604
+ //# sourceMappingURL=ashr.js.map