@alashi/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@ import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION,
2
2
  import semver from "semver";
3
3
  import { execFile } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- import { mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
6
- import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { z } from "zod";
@@ -11,6 +11,208 @@ import { Hono } from "hono";
11
11
  import { streamSSE } from "hono/streaming";
12
12
  import { Cron } from "croner";
13
13
  import { homedir } from "node:os";
14
+ //#region src/usage.ts
15
+ /** Days of history kept in usage.json; older days are dropped on write. */
16
+ const USAGE_RETENTION_DAYS = 90;
17
+ /** Cents-of-a-millicent precision — enough for per-turn costs, short enough to keep JSON tidy. */
18
+ const COST_PRECISION = 6;
19
+ const DAY_MS = 864e5;
20
+ /** The UTC calendar day (`YYYY-MM-DD`) a timestamp falls in. */
21
+ function utcDay(at = /* @__PURE__ */ new Date()) {
22
+ return at.toISOString().slice(0, 10);
23
+ }
24
+ function round(value) {
25
+ return Number(value.toFixed(COST_PRECISION));
26
+ }
27
+ /** Money for humans: sub-dollar spend needs the extra digits, larger sums don't. */
28
+ function formatUsd(value) {
29
+ return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
30
+ }
31
+ /**
32
+ * Per-app, per-UTC-day AI spend, persisted as OS bookkeeping in the app
33
+ * container (`apps/<name>/usage.json`, next to jobs.json and chat.json).
34
+ *
35
+ * Cost arrives from the `result` event's `costUsd`, which the SDK contract
36
+ * marks optional: engines that do not report cost simply contribute nothing.
37
+ * Writes are serialized per app so concurrent turns cannot lose an update,
38
+ * and each app has its own queue so a slow write for one app never delays
39
+ * another.
40
+ */
41
+ var UsageRecorder = class {
42
+ store;
43
+ retentionDays;
44
+ /** Serializes usage.json read-modify-write per app. */
45
+ queues = /* @__PURE__ */ new Map();
46
+ constructor(store, retentionDays = 90) {
47
+ this.store = store;
48
+ this.retentionDays = retentionDays;
49
+ }
50
+ /**
51
+ * Adds one turn's cost to the app's total for the UTC day of `at`.
52
+ * A missing, non-finite, or negative `costUsd` is ignored (no turn counted,
53
+ * no file written) so a provider that omits cost cannot corrupt the totals.
54
+ */
55
+ record(appName, costUsd, at = /* @__PURE__ */ new Date()) {
56
+ if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0) return Promise.resolve();
57
+ return this.enqueue(appName, async () => {
58
+ const state = await this.store.readUsageState(appName);
59
+ const date = utcDay(at);
60
+ const day = state.days.find((entry) => entry.date === date);
61
+ if (day) {
62
+ day.costUsd = round(day.costUsd + costUsd);
63
+ day.turns += 1;
64
+ } else state.days.push({
65
+ date,
66
+ costUsd: round(costUsd),
67
+ turns: 1
68
+ });
69
+ await this.store.writeUsageState(appName, this.prune(state, at));
70
+ });
71
+ }
72
+ /** The app's recorded days, oldest first, with expired days filtered out. */
73
+ async read(appName, at = /* @__PURE__ */ new Date()) {
74
+ return this.prune(await this.store.readUsageState(appName), at);
75
+ }
76
+ /** Today's spend plus the daily series, for the dashboard and the usage endpoint. */
77
+ async summary(appName, at = /* @__PURE__ */ new Date()) {
78
+ const { days } = await this.read(appName, at);
79
+ const date = utcDay(at);
80
+ const today = days.find((entry) => entry.date === date);
81
+ return {
82
+ date,
83
+ todayCostUsd: today?.costUsd ?? 0,
84
+ todayTurns: today?.turns ?? 0,
85
+ totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
86
+ retentionDays: this.retentionDays,
87
+ days
88
+ };
89
+ }
90
+ /** Drops days outside the retention window and normalizes ordering/values. */
91
+ prune(state, at) {
92
+ const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
93
+ return { days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => ({
94
+ date: entry.date,
95
+ costUsd: round(entry.costUsd),
96
+ turns: Number.isFinite(entry.turns) ? entry.turns : 0
97
+ })).sort((a, b) => a.date.localeCompare(b.date)) };
98
+ }
99
+ enqueue(appName, work) {
100
+ const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
101
+ const settled = next.catch(() => void 0);
102
+ this.queues.set(appName, settled);
103
+ settled.then(() => {
104
+ if (this.queues.get(appName) === settled) this.queues.delete(appName);
105
+ });
106
+ return next;
107
+ }
108
+ };
109
+ //#endregion
110
+ //#region src/budget.ts
111
+ /**
112
+ * Stable discriminator on the thrown error. Apps see the host's error object
113
+ * across a package boundary (and, once `AppContext` becomes RPC, across a
114
+ * process boundary), so the reliable check is this code — not `instanceof`.
115
+ */
116
+ const BUDGET_EXCEEDED_CODE = "budget_exceeded";
117
+ /**
118
+ * Refusal from the `createSession` budget gate.
119
+ *
120
+ * Extends the SDK's `AppSdkError` so it stays inside the taxonomy apps already
121
+ * catch, and carries a `code` plus the numbers behind the decision so an app
122
+ * can degrade gracefully (queue the work, skip the run, tell the user) instead
123
+ * of treating a spend cap as a crash. The message is written to stand alone:
124
+ * it is often the only text a human sees, rendered somewhere far from the host.
125
+ */
126
+ var BudgetExceededError = class extends AppSdkError {
127
+ code = BUDGET_EXCEEDED_CODE;
128
+ appName;
129
+ /** UTC day the cap applies to. */
130
+ date;
131
+ /** The configured cap in USD. */
132
+ limitUsd;
133
+ /** Spend already recorded for `date`. */
134
+ spentUsd;
135
+ constructor(appName, params) {
136
+ super(`Daily AI spend cap reached: app "${appName}" has spent ${formatUsd(params.spentUsd)} of its ${formatUsd(params.limitUsd)} cap for ${params.date} (UTC). New sessions are refused until the cap resets at 00:00 UTC, or until it is raised in the app's alashi settings.`);
137
+ this.name = "BudgetExceededError";
138
+ this.appName = appName;
139
+ this.date = params.date;
140
+ this.limitUsd = params.limitUsd;
141
+ this.spentUsd = params.spentUsd;
142
+ }
143
+ /** Duck-typed check that survives duplicate copies of this module. */
144
+ static is(err) {
145
+ return err instanceof Error && err.code === "budget_exceeded";
146
+ }
147
+ };
148
+ /**
149
+ * The cap the *host* has set for an app, or undefined when there is none.
150
+ *
151
+ * Read only from `GlobalConfig.budgets`, which is deliberately not part of the
152
+ * config layers merged into `ctx.config`: an app must not be able to raise its
153
+ * own ceiling through its manifest defaults or `apps/<name>/config.json`.
154
+ *
155
+ * A hand-edited cap that is not a finite number >= 0 is ignored rather than
156
+ * treated as zero — a typo in config.json should not brick an app, and the
157
+ * dashboard/settings write path validates before it ever gets stored.
158
+ */
159
+ function appBudget(config, name) {
160
+ const dailyUsd = config.budgets?.[name]?.dailyUsd;
161
+ if (typeof dailyUsd !== "number" || !Number.isFinite(dailyUsd) || dailyUsd < 0) return void 0;
162
+ return dailyUsd;
163
+ }
164
+ /** Whether `spentUsd` has reached `dailyUsd`; the cap itself is inclusive. */
165
+ function isOver(spentUsd, dailyUsd) {
166
+ return dailyUsd !== void 0 && spentUsd >= dailyUsd;
167
+ }
168
+ /** Combines an already-read usage summary with a cap; no I/O. */
169
+ function budgetStatusFor(summary, dailyUsd) {
170
+ return {
171
+ date: summary.date,
172
+ spentUsd: summary.todayCostUsd,
173
+ ...dailyUsd !== void 0 && {
174
+ dailyUsd,
175
+ remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
176
+ },
177
+ overBudget: isOver(summary.todayCostUsd, dailyUsd)
178
+ };
179
+ }
180
+ /** Today's spend against the app's cap, for the gate, the dashboard, and settings. */
181
+ async function budgetStatus(store, usage, name, at = /* @__PURE__ */ new Date()) {
182
+ const [config, summary] = await Promise.all([store.readGlobalConfig(), usage.summary(name, at)]);
183
+ return budgetStatusFor(summary, appBudget(config, name));
184
+ }
185
+ /**
186
+ * Fail-closed gate: throws `BudgetExceededError` when the app has already
187
+ * spent its daily cap. Evaluated fresh on every call, so a cap edit or a
188
+ * mid-day overspend takes effect without restarting the app.
189
+ */
190
+ async function assertWithinBudget(store, usage, name, at = /* @__PURE__ */ new Date()) {
191
+ const status = await budgetStatus(store, usage, name, at);
192
+ if (!status.overBudget || status.dailyUsd === void 0) return;
193
+ throw new BudgetExceededError(name, {
194
+ date: status.date,
195
+ limitUsd: status.dailyUsd,
196
+ spentUsd: status.spentUsd
197
+ });
198
+ }
199
+ /**
200
+ * Sets (or, with `undefined`, clears) an app's daily cap in the host config.
201
+ * Rejects anything that is not a finite, non-negative dollar amount; `0` is a
202
+ * legitimate value meaning "no AI spend at all for this app".
203
+ */
204
+ async function setAppBudget(store, name, dailyUsd) {
205
+ if (dailyUsd !== void 0 && (!Number.isFinite(dailyUsd) || dailyUsd < 0)) throw new AppSdkError(`Invalid daily spend cap: ${JSON.stringify(dailyUsd)}`);
206
+ const config = await store.readGlobalConfig();
207
+ const budgets = { ...config.budgets };
208
+ if (dailyUsd === void 0) delete budgets[name];
209
+ else budgets[name] = { dailyUsd };
210
+ const next = { ...config };
211
+ if (Object.keys(budgets).length === 0) delete next.budgets;
212
+ else next.budgets = budgets;
213
+ await store.writeGlobalConfig(next);
214
+ }
215
+ //#endregion
14
216
  //#region src/compat.ts
15
217
  /**
16
218
  * Enforces the manifest's declared `sdkVersion` range against the SDK
@@ -60,6 +262,11 @@ async function loadApp(repoDir) {
60
262
  }
61
263
  const definition = mod.default;
62
264
  if (typeof definition !== "object" || definition === null || !("manifest" in definition)) throw new AppSdkError("App entry must default-export an app definition (use defineApp() from @alashi/apps-sdk)");
265
+ const raw = definition.manifest;
266
+ if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
267
+ name: typeof raw.name === "string" ? raw.name : entry,
268
+ sdkVersion: raw.sdkVersion
269
+ });
63
270
  return {
64
271
  definition,
65
272
  manifest: parseManifest(definition.manifest),
@@ -68,7 +275,7 @@ async function loadApp(repoDir) {
68
275
  }
69
276
  //#endregion
70
277
  //#region src/sources.ts
71
- const execFileAsync$1 = promisify(execFile);
278
+ const execFileAsync$2 = promisify(execFile);
72
279
  function parseSourceArg(arg) {
73
280
  if (arg.startsWith(".") || isAbsolute(arg)) return {
74
281
  type: "local",
@@ -89,7 +296,7 @@ function parseSourceArg(arg) {
89
296
  }
90
297
  async function git(args, cwd) {
91
298
  try {
92
- const { stdout } = await execFileAsync$1("git", args, cwd ? { cwd } : {});
299
+ const { stdout } = await execFileAsync$2("git", args, cwd ? { cwd } : {});
93
300
  return stdout.trim();
94
301
  } catch (err) {
95
302
  const message = err instanceof Error ? err.message : String(err);
@@ -220,7 +427,7 @@ async function resolveApp(store, appName) {
220
427
  }
221
428
  //#endregion
222
429
  //#region src/installer.ts
223
- const execFileAsync = promisify(execFile);
430
+ const execFileAsync$1 = promisify(execFile);
224
431
  async function hasDependencies(dir) {
225
432
  try {
226
433
  const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
@@ -233,7 +440,7 @@ async function hasDependencies(dir) {
233
440
  async function installDependencies(dir) {
234
441
  if (!await hasDependencies(dir)) return;
235
442
  try {
236
- await execFileAsync("npm", [
443
+ await execFileAsync$1("npm", [
237
444
  "install",
238
445
  "--ignore-scripts",
239
446
  "--omit=dev",
@@ -275,7 +482,6 @@ async function installApp(store, source, options = {}) {
275
482
  }
276
483
  try {
277
484
  const { manifest } = await loadApp(inspectDir);
278
- checkSdkCompatibility(manifest);
279
485
  if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
280
486
  if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
281
487
  const repoDir = store.appRepoDir(manifest.name);
@@ -310,7 +516,6 @@ async function updateApp(store, name) {
310
516
  await updateSource(record.source, repoDir);
311
517
  await installDependencies(repoDir);
312
518
  const { manifest } = await loadApp(repoDir);
313
- checkSdkCompatibility(manifest);
314
519
  if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
315
520
  const updated = {
316
521
  ...record,
@@ -337,6 +542,276 @@ async function removeApp(store, name, options = {}) {
337
542
  await rm(store.appRecordPath(name), { force: true });
338
543
  }
339
544
  //#endregion
545
+ //#region src/portability.ts
546
+ const execFileAsync = promisify(execFile);
547
+ /**
548
+ * Container portability. An app is only half-portable on its own: the
549
+ * container holds sessionId pointers, but the conversation transcripts live
550
+ * with the engine (keyed by the app's data dir as cwd). An export therefore
551
+ * snapshots the container *plus* the transcripts, engine-neutrally, and the
552
+ * import re-keys them to this machine's data dir.
553
+ *
554
+ * Archive layout (tar.gz):
555
+ * alashi-export.json { formatVersion, exportedAt, app, transcripts }
556
+ * container/ the contents of $ALASHI_HOME/apps/<name>/
557
+ * transcripts/<sessionId>.jsonl
558
+ */
559
+ const EXPORT_FORMAT_VERSION = 1;
560
+ const METADATA_FILE = "alashi-export.json";
561
+ const CONTAINER_DIR = "container";
562
+ const TRANSCRIPTS_DIR = "transcripts";
563
+ const TRANSCRIPT_EXT = ".jsonl";
564
+ /** Re-derivable from the source, and by far the biggest thing in a container. */
565
+ const CONTAINER_EXCLUDES = [join("repo", "node_modules"), join("repo", ".git")];
566
+ /** Session ids become file names, so they must not be able to escape the dir. */
567
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
568
+ /** Shelling out to tar matches how git and npm are already invoked. */
569
+ async function tar(args) {
570
+ try {
571
+ const { stdout } = await execFileAsync("tar", args);
572
+ return stdout;
573
+ } catch (err) {
574
+ const message = err instanceof Error ? err.message : String(err);
575
+ throw new AppSdkError(`tar ${args[0]} failed: ${message}`, { cause: err });
576
+ }
577
+ }
578
+ async function exists(path) {
579
+ try {
580
+ await lstat(path);
581
+ return true;
582
+ } catch (err) {
583
+ if (err.code === "ENOENT") return false;
584
+ throw err;
585
+ }
586
+ }
587
+ async function isDirectory(path) {
588
+ try {
589
+ return (await stat(path)).isDirectory();
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+ function containerFilter(appDir) {
595
+ return (src) => {
596
+ const rel = relative(appDir, src);
597
+ if (rel === "") return true;
598
+ return !CONTAINER_EXCLUDES.some((excluded) => rel === excluded || rel.startsWith(excluded + sep));
599
+ };
600
+ }
601
+ /**
602
+ * Packs apps/<name>/ plus the transcripts of every conversation that ever had
603
+ * a turn into a single self-contained tar.gz.
604
+ */
605
+ async function exportApp(store, provider, name, options = {}) {
606
+ const record = await store.readAppRecord(name);
607
+ if (!record) throw new AppSdkError(`App "${name}" is not installed`);
608
+ const archivePath = resolve(process.cwd(), options.out ?? `${record.name}-${record.version}.tar.gz`);
609
+ if (await exists(archivePath)) throw new AppSdkError(`Refusing to overwrite existing file: ${archivePath}`);
610
+ const warnings = [];
611
+ const appDir = store.appDir(name);
612
+ const stage = join(store.stagingDir, randomUUID());
613
+ try {
614
+ await mkdir(stage, { recursive: true });
615
+ await cp(appDir, join(stage, CONTAINER_DIR), {
616
+ recursive: true,
617
+ dereference: true,
618
+ filter: containerFilter(appDir)
619
+ });
620
+ const { conversations } = await store.readChatState(name);
621
+ const sessionIds = [...new Set(conversations.map((conversation) => conversation.sessionId).filter((sessionId) => !!sessionId))];
622
+ let transcripts = [];
623
+ if (!provider.exportTranscripts) {
624
+ if (sessionIds.length > 0) warnings.push(`Provider "${provider.name}" cannot export transcripts; the archive holds the container only`);
625
+ } else if (sessionIds.length > 0) transcripts = await provider.exportTranscripts(sessionIds);
626
+ await mkdir(join(stage, TRANSCRIPTS_DIR), { recursive: true });
627
+ const written = [];
628
+ for (const transcript of transcripts) {
629
+ if (!SESSION_ID_PATTERN.test(transcript.sessionId)) {
630
+ warnings.push(`Skipped transcript with unusable session id ${JSON.stringify(transcript.sessionId)}`);
631
+ continue;
632
+ }
633
+ await writeFile(join(stage, TRANSCRIPTS_DIR, `${transcript.sessionId}${TRANSCRIPT_EXT}`), transcript.content, "utf8");
634
+ written.push(transcript.sessionId);
635
+ }
636
+ if (provider.exportTranscripts && written.length < sessionIds.length) warnings.push(`${sessionIds.length - written.length} of ${sessionIds.length} conversations had no transcript in "${provider.name}" storage`);
637
+ const metadata = {
638
+ formatVersion: 1,
639
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
640
+ app: record,
641
+ transcripts: written
642
+ };
643
+ await writeFile(join(stage, METADATA_FILE), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
644
+ await mkdir(dirname(archivePath), { recursive: true });
645
+ await tar([
646
+ "-czf",
647
+ archivePath,
648
+ "-C",
649
+ stage,
650
+ "."
651
+ ]);
652
+ return {
653
+ archivePath,
654
+ record,
655
+ transcriptCount: written.length,
656
+ warnings
657
+ };
658
+ } finally {
659
+ await rm(stage, {
660
+ recursive: true,
661
+ force: true
662
+ });
663
+ }
664
+ }
665
+ /** An archive is untrusted input: no absolute members, no ".." traversal. */
666
+ async function assertSafeMembers(archive) {
667
+ const listing = await tar(["-tzf", archive]);
668
+ for (const line of listing.split("\n")) {
669
+ const member = line.trim();
670
+ if (member === "") continue;
671
+ const segments = member.split(/[\\/]/);
672
+ if (isAbsolute(member) || member.startsWith("/") || segments.includes("..")) throw new AppSdkError(`Refusing to import ${archive}: it contains an unsafe path ${JSON.stringify(member)}`);
673
+ }
674
+ }
675
+ /** Second belt: a symlink inside the archive must not point out of the stage. */
676
+ async function assertNothingEscaped(stage) {
677
+ const root = resolve(stage);
678
+ const walk = async (dir) => {
679
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
680
+ const path = join(dir, entry.name);
681
+ if (entry.isSymbolicLink()) {
682
+ const target = resolve(dir, await readlink(path));
683
+ const rel = relative(root, target);
684
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new AppSdkError(`Refusing to import: ${relative(root, path)} links outside the archive (${target})`);
685
+ continue;
686
+ }
687
+ if (entry.isDirectory()) await walk(path);
688
+ }
689
+ };
690
+ await walk(root);
691
+ }
692
+ async function readMetadata(path) {
693
+ let raw;
694
+ try {
695
+ raw = await readFile(path, "utf8");
696
+ } catch (err) {
697
+ throw new AppSdkError(`Not an alashi export archive: ${METADATA_FILE} is missing`, { cause: err });
698
+ }
699
+ let parsed;
700
+ try {
701
+ parsed = JSON.parse(raw);
702
+ } catch (err) {
703
+ throw new AppSdkError(`Corrupt ${METADATA_FILE} in archive`, { cause: err });
704
+ }
705
+ const metadata = parsed;
706
+ if (metadata?.formatVersion !== 1) throw new AppSdkError(`Unsupported export formatVersion ${JSON.stringify(metadata?.formatVersion)}; this alashi reads formatVersion 1`);
707
+ return metadata;
708
+ }
709
+ async function readInstallRecord(path) {
710
+ let parsed;
711
+ try {
712
+ parsed = JSON.parse(await readFile(path, "utf8"));
713
+ } catch (err) {
714
+ throw new AppSdkError(`Archive has no readable ${CONTAINER_DIR}/app.json`, { cause: err });
715
+ }
716
+ const record = parsed;
717
+ if (!record || typeof record.name !== "string" || typeof record.version !== "string") throw new AppSdkError(`Archive ${CONTAINER_DIR}/app.json is not an install record`);
718
+ if (!APP_NAME_PATTERN.test(record.name)) throw new AppSdkError(`Archive carries an invalid app name: ${JSON.stringify(record.name)}`);
719
+ return record;
720
+ }
721
+ async function readTranscripts(dir) {
722
+ let entries;
723
+ try {
724
+ entries = await readdir(dir);
725
+ } catch (err) {
726
+ if (err.code === "ENOENT") return [];
727
+ throw err;
728
+ }
729
+ const transcripts = [];
730
+ for (const entry of entries.sort()) {
731
+ if (!entry.endsWith(TRANSCRIPT_EXT)) continue;
732
+ const sessionId = entry.slice(0, -6);
733
+ if (!SESSION_ID_PATTERN.test(sessionId)) continue;
734
+ transcripts.push({
735
+ sessionId,
736
+ content: await readFile(join(dir, entry), "utf8")
737
+ });
738
+ }
739
+ return transcripts;
740
+ }
741
+ /** Staging and apps/ are both under $ALASHI_HOME, so rename is the fast path. */
742
+ async function moveDir(from, to) {
743
+ try {
744
+ await rename(from, to);
745
+ } catch (err) {
746
+ if (err.code !== "EXDEV") throw err;
747
+ await cp(from, to, { recursive: true });
748
+ await rm(from, {
749
+ recursive: true,
750
+ force: true
751
+ });
752
+ }
753
+ }
754
+ /** Restores a container exported by {@link exportApp}, plus its transcripts. */
755
+ async function importApp(store, provider, archivePath) {
756
+ const archive = resolve(process.cwd(), archivePath);
757
+ if (!await exists(archive)) throw new AppSdkError(`Archive not found: ${archive}`);
758
+ await assertSafeMembers(archive);
759
+ const warnings = [];
760
+ const stage = join(store.stagingDir, randomUUID());
761
+ try {
762
+ await mkdir(stage, { recursive: true });
763
+ await tar([
764
+ "-xzf",
765
+ archive,
766
+ "-C",
767
+ stage,
768
+ "--no-same-owner"
769
+ ]);
770
+ await assertNothingEscaped(stage);
771
+ await readMetadata(join(stage, METADATA_FILE));
772
+ const containerDir = join(stage, CONTAINER_DIR);
773
+ if (!await isDirectory(containerDir)) throw new AppSdkError(`Archive has no ${CONTAINER_DIR}/ directory: ${archive}`);
774
+ const record = await readInstallRecord(join(containerDir, "app.json"));
775
+ const name = record.name;
776
+ if (await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is already installed (use: alashi remove ${name})`);
777
+ const appDir = store.appDir(name);
778
+ if (await exists(appDir)) throw new AppSdkError(`${appDir} already exists (leftover data from a previous install); delete it before importing`);
779
+ await mkdir(store.appsDir, { recursive: true });
780
+ await moveDir(containerDir, appDir);
781
+ try {
782
+ await mkdir(store.appDataDir(name), { recursive: true });
783
+ await installDependencies(store.appRepoDir(name));
784
+ } catch (err) {
785
+ const message = err instanceof Error ? err.message : String(err);
786
+ throw new AppSdkError(`Restored ${name} to ${appDir}, but reinstalling its dependencies failed: ${message}. Run "alashi update ${name}" before using it.`, { cause: err });
787
+ }
788
+ const transcripts = await readTranscripts(join(stage, TRANSCRIPTS_DIR));
789
+ let restored = 0;
790
+ if (transcripts.length > 0) {
791
+ if (!provider.importTranscripts) warnings.push(`Provider "${provider.name}" cannot import transcripts; ${transcripts.length} conversation transcripts were not restored`);
792
+ else {
793
+ try {
794
+ await provider.importTranscripts(transcripts, { cwd: store.appDataDir(name) });
795
+ } catch (err) {
796
+ const message = err instanceof Error ? err.message : String(err);
797
+ throw new AppSdkError(`Restored ${name} to ${appDir}, but restoring its transcripts failed: ${message}. The app works; its chat history does not.`, { cause: err });
798
+ }
799
+ restored = transcripts.length;
800
+ }
801
+ }
802
+ return {
803
+ record,
804
+ transcriptCount: restored,
805
+ warnings
806
+ };
807
+ } finally {
808
+ await rm(stage, {
809
+ recursive: true,
810
+ force: true
811
+ });
812
+ }
813
+ }
814
+ //#endregion
340
815
  //#region src/runtime.ts
341
816
  function createLogger(prefix) {
342
817
  const log = (level, message, data) => {
@@ -352,6 +827,30 @@ function createLogger(prefix) {
352
827
  };
353
828
  }
354
829
  /**
830
+ * Wraps a session so every `result` event's cost is accounted for before the
831
+ * event reaches the caller. This sits under *every* session an app opens —
832
+ * chat turns, cron job handlers, and app routes alike — so spend is recorded
833
+ * no matter who opened the session.
834
+ */
835
+ function meterSession(session, recordCost) {
836
+ return {
837
+ get id() {
838
+ return session.id;
839
+ },
840
+ send(input) {
841
+ const events = session.send(input);
842
+ return (async function* metered() {
843
+ for await (const event of events) {
844
+ if (event.type === "result") await recordCost(event.costUsd);
845
+ yield event;
846
+ }
847
+ })();
848
+ },
849
+ interrupt: () => session.interrupt(),
850
+ close: () => session.close()
851
+ };
852
+ }
853
+ /**
355
854
  * Wraps the host provider so every session an app opens automatically gets
356
855
  * its manifest-declared MCP servers, tool allowlist, agents, skills,
357
856
  * instructions, and data-dir cwd. Per-call options still win where they
@@ -362,6 +861,7 @@ function scopeProvider(provider, defaults) {
362
861
  name: provider.name,
363
862
  async createSession(options = {}) {
364
863
  if (defaults.unreadyError !== void 0) throw new ConfigError(defaults.unreadyError);
864
+ await defaults.checkBudget?.();
365
865
  const mcpServers = {
366
866
  ...defaults.mcpServers,
367
867
  ...options.mcpServers
@@ -372,7 +872,7 @@ function scopeProvider(provider, defaults) {
372
872
  ...options.agents
373
873
  };
374
874
  const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
375
- return provider.createSession({
875
+ const session = await provider.createSession({
376
876
  cwd: defaults.cwd,
377
877
  ...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
378
878
  ...options,
@@ -381,6 +881,7 @@ function scopeProvider(provider, defaults) {
381
881
  ...Object.keys(agents).length > 0 && { agents },
382
882
  ...skillDirs.length > 0 && { skillDirs }
383
883
  });
884
+ return defaults.recordCost ? meterSession(session, defaults.recordCost) : session;
384
885
  }
385
886
  };
386
887
  }
@@ -420,8 +921,12 @@ async function resolveAppConfig(store, manifest) {
420
921
  * Setup state shapes the session defaults: unconfigured MCP servers are
421
922
  * dropped (with their tools) so interpolation never hits a missing key, and
422
923
  * while required keys are missing the scoped provider fails closed.
924
+ *
925
+ * When a `usage` recorder is supplied, every session opened through the
926
+ * returned context reports its per-turn cost to it, and the app's host-set
927
+ * daily spend cap (if any) is enforced on every createSession.
423
928
  */
424
- async function buildAppContext(store, manifest, provider) {
929
+ async function buildAppContext(store, manifest, provider, usage) {
425
930
  const name = manifest.name;
426
931
  const dataDir = store.appDataDir(name);
427
932
  const repoDir = store.appRepoDir(name);
@@ -437,11 +942,19 @@ async function buildAppContext(store, manifest, provider) {
437
942
  const excluded = status.integrations.filter((i) => !i.configured).map((i) => i.name);
438
943
  const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
439
944
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
945
+ const logger = createLogger(name);
946
+ const recordCost = usage ? async (costUsd) => {
947
+ try {
948
+ await usage.record(name, costUsd);
949
+ } catch (err) {
950
+ logger.warn("Failed to record AI spend", { error: String(err) });
951
+ }
952
+ } : void 0;
440
953
  return {
441
954
  appName: name,
442
955
  dataDir,
443
956
  config,
444
- logger: createLogger(name),
957
+ logger,
445
958
  state: new JsonFileStore(join(dataDir, "state.json")),
446
959
  ai: scopeProvider(provider, {
447
960
  mcpServers,
@@ -450,6 +963,8 @@ async function buildAppContext(store, manifest, provider) {
450
963
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
451
964
  ...instructions !== void 0 && { instructions },
452
965
  cwd: dataDir,
966
+ ...recordCost !== void 0 && { recordCost },
967
+ ...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
453
968
  ...!status.ready && { unreadyError: `App "${name}" is not configured: missing ${status.missing.join(", ")}. Set the missing keys in the dashboard settings or ${store.appConfigPath(name)}.` }
454
969
  })
455
970
  };
@@ -616,12 +1131,15 @@ function settingsPage(name) {
616
1131
  .ro-item { margin-bottom: 12px; }
617
1132
  .ro-item:last-child { margin-bottom: 0; }
618
1133
  details summary { cursor: pointer; }
1134
+ #budget-card input { max-width: 180px; }
1135
+ #budget-status { margin-left: 12px; color: var(--muted); }
619
1136
  </style>
620
1137
  </head>
621
1138
  <body>
622
1139
  <header class="topbar"><a href="/">alashi</a> <span>/</span> <strong id="title"></strong> <span>settings</span></header>
623
1140
  <main class="narrow">
624
1141
  <div id="banner" class="banner" hidden></div>
1142
+ <div id="budget-card"></div>
625
1143
  <div id="cards"></div>
626
1144
  <button id="save" class="primary">Save changes</button><span id="status"></span>
627
1145
  <div id="manifest-cards"></div>
@@ -754,6 +1272,78 @@ function render(data) {
754
1272
  }
755
1273
  }
756
1274
 
1275
+ function usd(value) {
1276
+ return '$' + value.toFixed(value >= 1 ? 2 : 4);
1277
+ }
1278
+
1279
+ /**
1280
+ * Daily spend cap. A host setting, not an app config key: it lives in the
1281
+ * host's own config.json and is enforced in createSession, so an app cannot
1282
+ * raise its own ceiling through its manifest defaults or its config layer.
1283
+ */
1284
+ function renderBudget(b, message) {
1285
+ const root = document.getElementById('budget-card');
1286
+ root.replaceChildren();
1287
+ const capped = typeof b.dailyUsd === 'number';
1288
+
1289
+ const input = el('input', {
1290
+ type: 'number', min: '0', step: '0.01', placeholder: 'no cap',
1291
+ value: capped ? String(b.dailyUsd) : '',
1292
+ });
1293
+ const status = el('span', { id: 'budget-status', text: message || '' });
1294
+
1295
+ const save = el('button', {
1296
+ class: 'primary',
1297
+ text: 'Save cap',
1298
+ onclick: async () => {
1299
+ const raw = input.value.trim();
1300
+ const dailyUsd = raw === '' ? null : Number(raw);
1301
+ if (dailyUsd !== null && !(dailyUsd >= 0)) {
1302
+ status.textContent = 'Enter a dollar amount, or leave empty for no cap.';
1303
+ return;
1304
+ }
1305
+ status.textContent = 'Saving…';
1306
+ const res = await fetch('/api/apps/' + APP + '/budget', {
1307
+ method: 'PUT',
1308
+ headers: { 'content-type': 'application/json' },
1309
+ body: JSON.stringify({ dailyUsd }),
1310
+ });
1311
+ if (!res.ok) {
1312
+ status.textContent = 'Save failed (' + res.status + ')';
1313
+ return;
1314
+ }
1315
+ renderBudget(await res.json(), dailyUsd === null ? 'Cap removed.' : 'Cap saved.');
1316
+ },
1317
+ });
1318
+
1319
+ const spent = 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd);
1320
+ const body = [
1321
+ el('div', { class: 'desc', text:
1322
+ 'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
1323
+ + 'this app are refused, and the counter resets at 00:00 UTC. This is a host setting — the '
1324
+ + 'app cannot read or raise it through its own config.' }),
1325
+ el('div', { class: 'field' }, [
1326
+ el('label', { text: 'Daily spend cap (USD)' }),
1327
+ input,
1328
+ ]),
1329
+ el('div', { class: 'desc', text: capped
1330
+ ? spent + ' — ' + usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
1331
+ : spent + ' — no cap set, spend is unlimited' }),
1332
+ el('div', { style: 'margin-top:12px' }, [save, status]),
1333
+ ];
1334
+ if (b.overBudget) {
1335
+ body.unshift(el('div', { class: 'banner warn', text:
1336
+ 'Over budget — new AI sessions are refused. ' + usd(b.spentUsd) + ' spent of the '
1337
+ + usd(b.dailyUsd) + ' daily cap for ' + b.date + ' (UTC). Raise or clear the cap below, '
1338
+ + 'or wait for the reset at 00:00 UTC.' }));
1339
+ }
1340
+
1341
+ root.append(el('div', { class: 'card' }, [
1342
+ el('h2', { text: 'Spend cap' }),
1343
+ el('div', { class: 'body' }, body),
1344
+ ]));
1345
+ }
1346
+
757
1347
  function roCard(title, children) {
758
1348
  return el('div', { class: 'card' }, [
759
1349
  el('h2', { text: title }),
@@ -827,10 +1417,13 @@ function renderManifest(m) {
827
1417
  }
828
1418
 
829
1419
  async function load() {
830
- const [res, manRes] = await Promise.all([
1420
+ const [res, manRes, budgetRes] = await Promise.all([
831
1421
  fetch('/api/apps/' + APP + '/config'),
832
1422
  fetch('/api/apps/' + APP + '/manifest'),
1423
+ fetch('/api/apps/' + APP + '/budget'),
833
1424
  ]);
1425
+ // Host bookkeeping: the cap renders even when the app itself fails to load.
1426
+ if (budgetRes.ok) renderBudget(await budgetRes.json());
834
1427
  if (!res.ok) {
835
1428
  document.getElementById('banner').hidden = false;
836
1429
  document.getElementById('banner').className = 'banner warn';
@@ -882,14 +1475,44 @@ const CONTENT_TYPES = {
882
1475
  function escapeHtml(value) {
883
1476
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
884
1477
  }
1478
+ /** Host-rendered failure page — apps never need to ship their own error UI. */
1479
+ function appErrorPage(name, err) {
1480
+ return `<!doctype html>
1481
+ <html lang="en">
1482
+ <head>
1483
+ <meta charset="utf-8">
1484
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1485
+ <title>${escapeHtml(name)} — failed to load</title>
1486
+ <link rel="stylesheet" href="/assets/v1/system.css">
1487
+ </head>
1488
+ <body>
1489
+ <header class="topbar"><strong>alashi</strong></header>
1490
+ <main class="narrow">
1491
+ <h1>${escapeHtml(name)} failed to load</h1>
1492
+ <div class="panel">
1493
+ <p>${escapeHtml(String(err))}</p>
1494
+ <p class="muted"><a href="/">Back to apps</a></p>
1495
+ </div>
1496
+ </main>
1497
+ </body>
1498
+ </html>
1499
+ `;
1500
+ }
885
1501
  /**
886
1502
  * The host HTTP surface:
887
1503
  * GET / dashboard of installed apps
888
1504
  * GET /settings/:name per-app settings page (config form)
889
- * GET /api/apps installed apps as JSON
1505
+ * GET /api/apps installed apps as JSON, each with todayCostUsd
1506
+ * and its daily spend cap / over-budget state
1507
+ * GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
1508
+ * and the budget status
1509
+ * GET /api/apps/:name/budget today's spend against the app's daily cap
1510
+ * PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
1511
+ * (a host setting; apps cannot set their own)
890
1512
  * POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
891
1513
  * (omit conversationId to start a new conversation; the
892
- * id is echoed in the x-conversation-id response header)
1514
+ * id is echoed in the x-conversation-id response header;
1515
+ * 402 when the app is over its daily spend cap)
893
1516
  * GET /api/apps/:name/chat/conversations conversations, most recent first
894
1517
  * POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
895
1518
  * optionally adopting an existing provider session (e.g. an
@@ -912,9 +1535,10 @@ function escapeHtml(value) {
912
1535
  function createServer(store, provider) {
913
1536
  const apps = /* @__PURE__ */ new Map();
914
1537
  const sessions = /* @__PURE__ */ new Map();
1538
+ const usage = new UsageRecorder(store);
915
1539
  async function startApp(name) {
916
1540
  const { definition, manifest } = await loadApp(store.appRepoDir(name));
917
- const ctx = await buildAppContext(store, manifest, provider);
1541
+ const ctx = await buildAppContext(store, manifest, provider, usage);
918
1542
  return {
919
1543
  manifest,
920
1544
  ctx,
@@ -1021,35 +1645,46 @@ function createServer(store, provider) {
1021
1645
  return c.json({ error: "Not found" }, 404);
1022
1646
  }
1023
1647
  });
1024
- /** Readiness for the dashboard badge; undefined when the app fails to load. */
1648
+ /** Readiness for the dashboard badge; loadError carries the failure reason. */
1025
1649
  async function readiness(name) {
1026
1650
  try {
1027
1651
  const running = await getApp(name);
1028
1652
  if (!running) return void 0;
1029
1653
  return (await resolveAppConfig(store, running.manifest)).status;
1030
- } catch {
1031
- return;
1654
+ } catch (err) {
1655
+ return { loadError: String(err) };
1032
1656
  }
1033
1657
  }
1034
1658
  app.get("/", async (c) => {
1035
1659
  const records = await store.listInstalled();
1036
- const rows = (await Promise.all(records.map(async (r) => {
1660
+ const cells = await Promise.all(records.map(async (r) => {
1037
1661
  const name = escapeHtml(r.name);
1038
1662
  const status = await readiness(r.name);
1039
- const badge = !status ? "<span class=\"badge err\">failed to load</span>" : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
1040
- return `<tr>
1663
+ const badge = !status ? "<span class=\"badge err\">failed to load</span>" : "loadError" in status ? `<span class="badge err" title="${escapeHtml(status.loadError)}">failed to load</span>` : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
1664
+ const budget = await budgetStatus(store, usage, r.name);
1665
+ const todayCostUsd = budget.spentUsd;
1666
+ const overBadge = budget.overBudget ? ` <a href="/settings/${name}" title="Daily AI spend cap reached — new sessions are refused until 00:00 UTC"><span class="badge err">over budget</span></a>` : "";
1667
+ const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
1668
+ return {
1669
+ row: `<tr>
1041
1670
  <td><a href="/app/${name}/">${name}</a></td>
1042
1671
  <td class="muted">v${escapeHtml(r.version)}</td>
1043
1672
  <td class="muted">${escapeHtml(r.source.type)}</td>
1044
- <td>${badge}</td>
1673
+ <td>${badge}${overBadge}</td>
1674
+ <td class="muted" style="text-align:right">${formatUsd(todayCostUsd)}${cap}</td>
1045
1675
  <td style="text-align:right"><a href="/settings/${name}">settings</a></td>
1046
- </tr>`;
1047
- }))).join("\n");
1676
+ </tr>`,
1677
+ todayCostUsd
1678
+ };
1679
+ }));
1680
+ const rows = cells.map((cell) => cell.row).join("\n");
1681
+ const totalToday = cells.reduce((sum, cell) => sum + cell.todayCostUsd, 0);
1048
1682
  const body = records.length === 0 ? "<div class=\"empty\">No apps installed. Run <code>alashi install &lt;name&gt;</code>.</div>" : `<table>
1049
- <thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th></th></tr></thead>
1683
+ <thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="AI spend today (UTC)">Today</th><th></th></tr></thead>
1050
1684
  <tbody>
1051
1685
  ${rows}
1052
1686
  </tbody>
1687
+ <tfoot><tr><td colspan="4" class="muted">AI spend today (UTC)</td><td class="muted" style="text-align:right">${formatUsd(totalToday)}</td><td></td></tr></tfoot>
1053
1688
  </table>`;
1054
1689
  return c.html(`<!doctype html>
1055
1690
  <html lang="en">
@@ -1072,7 +1707,58 @@ ${body}
1072
1707
  </html>
1073
1708
  `);
1074
1709
  });
1075
- app.get("/api/apps", async (c) => c.json({ apps: await store.listInstalled() }));
1710
+ app.get("/api/apps", async (c) => {
1711
+ const records = await store.listInstalled();
1712
+ const apps = await Promise.all(records.map(async (record) => {
1713
+ const budget = await budgetStatus(store, usage, record.name);
1714
+ return {
1715
+ ...record,
1716
+ todayCostUsd: budget.spentUsd,
1717
+ overBudget: budget.overBudget,
1718
+ ...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
1719
+ };
1720
+ }));
1721
+ return c.json({ apps });
1722
+ });
1723
+ /** Spend and budget are host bookkeeping: readable even when the app fails to load. */
1724
+ async function installedApp(name) {
1725
+ return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
1726
+ }
1727
+ app.get("/api/apps/:name/usage", async (c) => {
1728
+ const name = c.req.param("name");
1729
+ if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
1730
+ const [summary, config] = await Promise.all([usage.summary(name), store.readGlobalConfig()]);
1731
+ return c.json({
1732
+ ...summary,
1733
+ budget: budgetStatusFor(summary, appBudget(config, name))
1734
+ });
1735
+ });
1736
+ app.get("/api/apps/:name/budget", async (c) => {
1737
+ const name = c.req.param("name");
1738
+ if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
1739
+ return c.json(await budgetStatus(store, usage, name));
1740
+ });
1741
+ /**
1742
+ * Sets the app's daily spend cap. A host setting, not an app config key: it
1743
+ * never touches `apps/<name>/config.json`, so the app cannot raise it via
1744
+ * PUT /api/apps/:name/config or by shipping a manifest default.
1745
+ */
1746
+ app.put("/api/apps/:name/budget", async (c) => {
1747
+ const name = c.req.param("name");
1748
+ if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
1749
+ let body;
1750
+ try {
1751
+ body = await c.req.json();
1752
+ } catch {
1753
+ return c.json({ error: "Body must be JSON" }, 400);
1754
+ }
1755
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Body must be { \"dailyUsd\": number | null }" }, 400);
1756
+ const { dailyUsd } = body;
1757
+ if (dailyUsd !== null && (typeof dailyUsd !== "number" || !Number.isFinite(dailyUsd))) return c.json({ error: "\"dailyUsd\" must be a number, or null to remove the cap" }, 400);
1758
+ if (typeof dailyUsd === "number" && dailyUsd < 0) return c.json({ error: "\"dailyUsd\" must not be negative" }, 400);
1759
+ await setAppBudget(store, name, dailyUsd === null ? void 0 : dailyUsd);
1760
+ return c.json(await budgetStatus(store, usage, name));
1761
+ });
1076
1762
  app.get("/settings/:name", async (c) => {
1077
1763
  const name = c.req.param("name");
1078
1764
  if (!APP_NAME_PATTERN.test(name) || !await store.readAppRecord(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -1241,6 +1927,12 @@ ${body}
1241
1927
  }
1242
1928
  if (typeof message !== "string" || message.length === 0) return c.json({ error: "Body must be { \"message\": string }" }, 400);
1243
1929
  if (conversationId !== void 0 && typeof conversationId !== "string") return c.json({ error: "\"conversationId\" must be a string" }, 400);
1930
+ try {
1931
+ await assertWithinBudget(store, usage, name);
1932
+ } catch (err) {
1933
+ if (BudgetExceededError.is(err)) return c.json({ error: err.message }, 402);
1934
+ throw err;
1935
+ }
1244
1936
  const state = await getChatState(name);
1245
1937
  let convo;
1246
1938
  if (conversationId !== void 0) {
@@ -1298,7 +1990,13 @@ ${body}
1298
1990
  app.get("/app/:name", (c) => c.redirect(`/app/${c.req.param("name")}/`));
1299
1991
  app.all("/app/:name/*", async (c) => {
1300
1992
  const name = c.req.param("name");
1301
- const running = await getApp(name);
1993
+ let running;
1994
+ try {
1995
+ running = await getApp(name);
1996
+ } catch (err) {
1997
+ if (c.req.method === "GET" && (c.req.header("accept") ?? "").includes("text/html")) return c.html(appErrorPage(name, err), 500);
1998
+ throw err;
1999
+ }
1302
2000
  if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
1303
2001
  if (c.req.method === "GET" || c.req.method === "HEAD") {
1304
2002
  const file = await serveStatic(name, running.manifest, c.req.path);
@@ -1383,6 +2081,9 @@ var Store = class {
1383
2081
  appJobStatePath(name) {
1384
2082
  return join(this.appDir(name), "jobs.json");
1385
2083
  }
2084
+ appUsagePath(name) {
2085
+ return join(this.appDir(name), "usage.json");
2086
+ }
1386
2087
  registryCachePath(name) {
1387
2088
  if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
1388
2089
  return join(this.registriesDir, `${name}.json`);
@@ -1421,6 +2122,12 @@ var Store = class {
1421
2122
  async writeJobState(name, state) {
1422
2123
  await writeJson(this.appJobStatePath(name), state);
1423
2124
  }
2125
+ async readUsageState(name) {
2126
+ return { days: (await readJson(this.appUsagePath(name)))?.days ?? [] };
2127
+ }
2128
+ async writeUsageState(name, state) {
2129
+ await writeJson(this.appUsagePath(name), state);
2130
+ }
1424
2131
  async listInstalled() {
1425
2132
  let entries;
1426
2133
  try {
@@ -1439,6 +2146,6 @@ var Store = class {
1439
2146
  }
1440
2147
  };
1441
2148
  //#endregion
1442
- export { checkSdkCompatibility as _, installApp as a, addRegistry as c, removeRegistry as d, resolveApp as f, loadApp as g, updateSource as h, resolveAppConfig as i, readCachedIndex as l, parseSourceArg as m, createServer as n, removeApp as o, fetchSource as p, buildAppContext as r, updateApp as s, Store as t, refreshRegistry as u };
2149
+ export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, utcDay as M, setAppBudget as O, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
1443
2150
 
1444
- //# sourceMappingURL=store-Cxxnjq70.mjs.map
2151
+ //# sourceMappingURL=store-VoJe_U1Q.mjs.map