@alashi/cli 0.14.0 → 0.15.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.
@@ -3,7 +3,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
3
  import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
4
4
  import semver from "semver";
5
5
  import { execFile } from "node:child_process";
6
- import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
6
+ import { appendFile, cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
7
7
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -368,6 +368,25 @@ function round(value) {
368
368
  function formatUsd(value) {
369
369
  return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
370
370
  }
371
+ /**
372
+ * The metering a turn actually contributes, or `undefined` when the engine
373
+ * reported nothing usable.
374
+ *
375
+ * A turn counts when its cost is a finite, non-negative number **or** it
376
+ * reported per-model tokens — an engine that omits both cannot corrupt the
377
+ * totals. This is the single gate for both writes: the ledger line and the
378
+ * usage.json fold must agree on what a turn is, or a rebuild from the ledger
379
+ * would disagree with the live aggregate.
380
+ */
381
+ function meteredTurn(turn) {
382
+ const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
383
+ const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
384
+ if (!costValid && modelUsage === void 0) return void 0;
385
+ return {
386
+ costUsd: costValid ? turn.costUsd : 0,
387
+ ...modelUsage !== void 0 && { modelUsage }
388
+ };
389
+ }
371
390
  /** Merges per-app summaries into the host-wide view. */
372
391
  function combineSummaries(summaries) {
373
392
  const combined = {
@@ -453,38 +472,59 @@ function sanitizeModels(models) {
453
472
  }
454
473
  /**
455
474
  * Per-app AI spend and token usage, persisted as OS bookkeeping in the app
456
- * container (`apps/<name>/usage.json`, next to jobs.json and chat.json):
457
- * a per-UTC-day series trimmed to the retention window, plus per-month
458
- * rollups that are kept forever.
475
+ * container next to jobs.json and chat.json, in two files:
476
+ *
477
+ * - `apps/<name>/turns.jsonl` — the ledger: one line per metered turn,
478
+ * append-only, oldest first, carrying the session attribution the
479
+ * aggregates throw away.
480
+ * - `apps/<name>/usage.json` — a fold over that ledger: a per-UTC-day series
481
+ * trimmed to the retention window, plus per-month rollups kept forever.
482
+ *
483
+ * The two are written together inside one per-app queue, so a turn can never
484
+ * land in one and not the other, and concurrent turns can neither lose an
485
+ * update nor interleave a half-written line. Each app has its own queue, so a
486
+ * slow write for one app never delays another.
459
487
  *
460
488
  * Cost and per-model tokens arrive from the `result` event, whose fields the
461
489
  * SDK contract marks optional: engines that report neither simply contribute
462
- * nothing. Writes are serialized per app so concurrent turns cannot lose an
463
- * update, and each app has its own queue so a slow write for one app never
464
- * delays another.
490
+ * nothing.
465
491
  */
466
492
  var UsageRecorder = class {
467
493
  store;
468
494
  retentionDays;
469
- /** Serializes usage.json read-modify-write per app. */
495
+ /** Serializes the usage.json read-modify-write and ledger append per app. */
470
496
  queues = /* @__PURE__ */ new Map();
497
+ /** Apps whose ledger has already been compacted in this process. */
498
+ compacted = /* @__PURE__ */ new Set();
471
499
  constructor(store, retentionDays = 90) {
472
500
  this.store = store;
473
501
  this.retentionDays = retentionDays;
474
502
  }
475
503
  /**
476
- * Adds one turn's cost and token usage to the app's totals for the UTC day
477
- * and month of `at`. A missing, non-finite, or negative `costUsd` is
478
- * ignored; a turn that reports neither valid cost nor any model usage is
479
- * not counted and nothing is written, so a provider that omits metering
480
- * cannot corrupt the totals.
504
+ * Appends the turn to the app's ledger and adds its cost and token usage to
505
+ * the totals for the UTC day and month of `at`. A missing, non-finite, or
506
+ * negative `costUsd` is ignored; a turn that reports neither valid cost nor
507
+ * any model usage is not counted and nothing is written at all, so a
508
+ * provider that omits metering cannot corrupt the totals.
509
+ *
510
+ * Both writes happen in the same queued unit of work, ledger first: if the
511
+ * aggregate write then fails, the ledger is *ahead* of usage.json and
512
+ * `rebuild()` can recover the turn. The other order would lose it.
481
513
  */
482
514
  record(appName, turn, at = /* @__PURE__ */ new Date()) {
483
- const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
484
- const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
485
- if (!costValid && modelUsage === void 0) return Promise.resolve();
486
- const costUsd = costValid ? turn.costUsd : 0;
515
+ const metered = meteredTurn(turn);
516
+ if (metered === void 0) return Promise.resolve();
517
+ const { costUsd, modelUsage } = metered;
518
+ const line = {
519
+ at: at.toISOString(),
520
+ ...turn.sessionId !== void 0 && turn.sessionId !== "" && { sessionId: turn.sessionId },
521
+ ...turn.model !== void 0 && turn.model !== "" && { model: turn.model },
522
+ costUsd,
523
+ ...modelUsage !== void 0 && { modelUsage }
524
+ };
487
525
  return this.enqueue(appName, async () => {
526
+ await this.compactOnce(appName, at);
527
+ await this.store.appendTurnRecord(appName, line);
488
528
  const state = this.normalize(await this.store.readUsageState(appName));
489
529
  const date = utcDay(at);
490
530
  const day = state.days.find((entry) => entry.date === date);
@@ -539,6 +579,117 @@ var UsageRecorder = class {
539
579
  months
540
580
  };
541
581
  }
582
+ /**
583
+ * The app's metered turns, oldest first, with expired lines excluded.
584
+ *
585
+ * `since` is an inclusive lower bound compared lexically against the ISO
586
+ * `at` (so a plain `YYYY-MM-DD` works as "from that day on"), `sessionId`
587
+ * narrows to one provider session, and both are ANDed. This is the one
588
+ * reader for the ledger: everything that needs per-turn detail goes through
589
+ * it rather than parsing the file itself.
590
+ */
591
+ async turns(appName, query = {}, at = /* @__PURE__ */ new Date()) {
592
+ const cutoff = this.cutoff(at);
593
+ return (await this.store.readTurnRecords(appName)).filter((record) => this.inWindow(record, cutoff) && (query.since === void 0 || record.at >= query.since) && (query.sessionId === void 0 || record.sessionId === query.sessionId)).sort((a, b) => a.at.localeCompare(b.at));
594
+ }
595
+ /**
596
+ * Rewrites the app's ledger without the lines that have aged out of the
597
+ * retention window (the same window usage.json days use). Unparseable lines
598
+ * are dropped by the same rewrite, since only parsed records are written
599
+ * back.
600
+ *
601
+ * Runs inside the per-app queue, so it can never race a turn being
602
+ * appended. Pure bookkeeping: callers should treat a failure as noise, not
603
+ * as a reason to fail whatever they were doing.
604
+ */
605
+ compact(appName, at = /* @__PURE__ */ new Date()) {
606
+ return this.enqueue(appName, async () => {
607
+ await this.compactNow(appName, at);
608
+ this.compacted.add(appName);
609
+ });
610
+ }
611
+ /**
612
+ * Rebuilds usage.json from turns.jsonl, for when the two have drifted apart
613
+ * (a lost write, a crash mid-fold, a hand edit). The result is exactly what
614
+ * the incremental fold in `record()` would have produced from the same
615
+ * lines: same day and month buckets, same turn counts, same per-model
616
+ * totals, same rounding.
617
+ *
618
+ * Caveat, by construction: the ledger is pruned at `retentionDays` but
619
+ * usage.json months are kept forever, so months the ledger no longer covers
620
+ * cannot be recomputed. Those keep their existing rollup and only the
621
+ * months the ledger does cover are replaced — a rebuild never silently
622
+ * erases older history. The one month that is *partially* covered (the one
623
+ * the retention cutoff falls in) is replaced by its in-window part, which is
624
+ * all the ledger can still account for.
625
+ *
626
+ * Returns the state it wrote.
627
+ */
628
+ rebuild(appName, at = /* @__PURE__ */ new Date()) {
629
+ return this.enqueue(appName, async () => {
630
+ const cutoff = this.cutoff(at);
631
+ const [records, existing] = await Promise.all([this.store.readTurnRecords(appName), this.store.readUsageState(appName)]);
632
+ const previous = this.normalize(existing);
633
+ const days = /* @__PURE__ */ new Map();
634
+ const months = /* @__PURE__ */ new Map();
635
+ for (const record of records) {
636
+ if (!this.inWindow(record, cutoff)) continue;
637
+ const metered = meteredTurn(record);
638
+ if (metered === void 0) continue;
639
+ const date = record.at.slice(0, 10);
640
+ const monthKey = record.at.slice(0, 7);
641
+ const day = days.get(date) ?? {
642
+ date,
643
+ costUsd: 0,
644
+ turns: 0
645
+ };
646
+ this.apply(day, metered.costUsd, metered.modelUsage);
647
+ days.set(date, day);
648
+ const month = months.get(monthKey) ?? {
649
+ month: monthKey,
650
+ costUsd: 0,
651
+ turns: 0
652
+ };
653
+ this.apply(month, metered.costUsd, metered.modelUsage);
654
+ months.set(monthKey, month);
655
+ }
656
+ const kept = previous.months.filter((month) => !months.has(month.month));
657
+ const state = this.prune({
658
+ days: [...days.values()],
659
+ months: [...kept, ...months.values()]
660
+ }, at);
661
+ await this.store.writeUsageState(appName, state);
662
+ return state;
663
+ });
664
+ }
665
+ /** The oldest UTC day still inside the retention window at `at`. */
666
+ cutoff(at) {
667
+ return utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
668
+ }
669
+ inWindow(record, cutoff) {
670
+ return record.at.slice(0, 10) >= cutoff;
671
+ }
672
+ /** The compaction body; assumes it is already inside the app's queue. */
673
+ async compactNow(appName, at) {
674
+ const cutoff = this.cutoff(at);
675
+ const records = await this.store.readTurnRecords(appName);
676
+ const kept = records.filter((record) => this.inWindow(record, cutoff));
677
+ if (kept.length === records.length) return;
678
+ await this.store.writeTurnRecords(appName, kept);
679
+ }
680
+ /**
681
+ * Trims the ledger on the app's first append of the process, so a host that
682
+ * is never restarted still sheds expired lines. Compaction is bookkeeping
683
+ * and must never break a turn, so a failure is swallowed — the next process
684
+ * tries again, and meanwhile `turns()` filters expired lines out anyway.
685
+ */
686
+ async compactOnce(appName, at) {
687
+ if (this.compacted.has(appName)) return;
688
+ this.compacted.add(appName);
689
+ try {
690
+ await this.compactNow(appName, at);
691
+ } catch {}
692
+ }
542
693
  apply(bucket, costUsd, modelUsage) {
543
694
  bucket.costUsd = round(bucket.costUsd + costUsd);
544
695
  bucket.turns += 1;
@@ -582,7 +733,7 @@ var UsageRecorder = class {
582
733
  }
583
734
  /** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
584
735
  prune(state, at) {
585
- const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
736
+ const cutoff = this.cutoff(at);
586
737
  return {
587
738
  days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
588
739
  const models = sanitizeModels(entry.models);
@@ -1355,8 +1506,14 @@ function createLogger(prefix) {
1355
1506
  * event reaches the caller. This sits under *every* session an app opens —
1356
1507
  * chat turns, cron job handlers, and app routes alike — so spend is recorded
1357
1508
  * no matter who opened the session.
1509
+ *
1510
+ * The session id and model come from the `session` event, which a provider
1511
+ * emits once and which then holds for every later turn — so they are
1512
+ * remembered in this closure, not per `send()` call.
1358
1513
  */
1359
1514
  function meterSession(session, recordUsage) {
1515
+ let sessionId;
1516
+ let model;
1360
1517
  return {
1361
1518
  get id() {
1362
1519
  return session.id;
@@ -1365,16 +1522,66 @@ function meterSession(session, recordUsage) {
1365
1522
  const events = session.send(input);
1366
1523
  return (async function* metered() {
1367
1524
  for await (const event of events) {
1368
- if (event.type === "result") await recordUsage({
1369
- costUsd: event.costUsd,
1370
- modelUsage: event.modelUsage
1371
- });
1525
+ if (event.type === "session") {
1526
+ if (event.sessionId !== "") sessionId = event.sessionId;
1527
+ if (event.model !== void 0 && event.model !== "") model = event.model;
1528
+ }
1529
+ if (event.type === "result") {
1530
+ const id = sessionId ?? (session.id === "" ? void 0 : session.id);
1531
+ await recordUsage({
1532
+ costUsd: event.costUsd,
1533
+ modelUsage: event.modelUsage,
1534
+ ...id !== void 0 && { sessionId: id },
1535
+ ...model !== void 0 && { model }
1536
+ });
1537
+ }
1372
1538
  yield event;
1373
1539
  }
1374
1540
  })();
1375
1541
  },
1376
1542
  interrupt: () => session.interrupt(),
1377
- close: () => session.close()
1543
+ close: () => session.close(),
1544
+ ...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
1545
+ };
1546
+ }
1547
+ /**
1548
+ * Wraps a session so its approval traffic is mirrored into the host-wide
1549
+ * ApprovalRegistry. Layered like `meterSession` under every session an app
1550
+ * opens, which is what makes a cron job's approval visible in the host's
1551
+ * pending list instead of hanging silently until timeout.
1552
+ *
1553
+ * A stream that ends while approvals are still pending (crash, close) abandons
1554
+ * them, so the registry never carries a pending entry no session can answer.
1555
+ */
1556
+ function superviseSession(session, app, registry) {
1557
+ return {
1558
+ get id() {
1559
+ return session.id;
1560
+ },
1561
+ send(input) {
1562
+ const events = session.send(input);
1563
+ const open = /* @__PURE__ */ new Set();
1564
+ return (async function* supervised() {
1565
+ try {
1566
+ for await (const event of events) {
1567
+ if (event.type === "approval_required") {
1568
+ open.add(event.approvalId);
1569
+ registry.track(app, event, (decision) => session.respondToApproval ? session.respondToApproval(event.approvalId, decision) : Promise.resolve());
1570
+ }
1571
+ if (event.type === "approval_resolved") {
1572
+ open.delete(event.approvalId);
1573
+ registry.resolved(event);
1574
+ }
1575
+ yield event;
1576
+ }
1577
+ } finally {
1578
+ for (const approvalId of open) registry.abandon(approvalId);
1579
+ }
1580
+ })();
1581
+ },
1582
+ interrupt: () => session.interrupt(),
1583
+ close: () => session.close(),
1584
+ ...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
1378
1585
  };
1379
1586
  }
1380
1587
  /**
@@ -1393,26 +1600,80 @@ function scopeProvider(provider, defaults) {
1393
1600
  ...defaults.mcpServers,
1394
1601
  ...options.mcpServers
1395
1602
  };
1396
- const allowedTools = [.../* @__PURE__ */ new Set([...defaults.allowedTools, ...options.allowedTools ?? []])];
1603
+ const granted = (defaults.readGrants ? await defaults.readGrants() : []).filter((tool) => defaults.askTools.includes(tool));
1604
+ const allowedTools = [.../* @__PURE__ */ new Set([
1605
+ ...defaults.allowedTools,
1606
+ ...granted,
1607
+ ...options.allowedTools ?? []
1608
+ ])];
1609
+ const askTools = [.../* @__PURE__ */ new Set([...defaults.askTools.filter((tool) => !granted.includes(tool)), ...options.askTools ?? []])];
1397
1610
  const agents = {
1398
1611
  ...defaults.agents,
1399
1612
  ...options.agents
1400
1613
  };
1401
1614
  const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
1402
- const session = await provider.createSession({
1615
+ let session = await provider.createSession({
1403
1616
  cwd: defaults.cwd,
1404
1617
  ...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
1405
1618
  ...options,
1406
1619
  ...Object.keys(mcpServers).length > 0 && { mcpServers },
1407
1620
  ...allowedTools.length > 0 && { allowedTools },
1621
+ ...askTools.length > 0 && { askTools },
1408
1622
  ...Object.keys(agents).length > 0 && { agents },
1409
1623
  ...skillDirs.length > 0 && { skillDirs }
1410
1624
  });
1625
+ if (defaults.supervise) session = defaults.supervise(session);
1411
1626
  return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
1412
1627
  }
1413
1628
  };
1414
1629
  }
1415
1630
  /**
1631
+ * A summary that says "nothing has been metered", for a context built without
1632
+ * a recorder (tests, and callers that only need config and state).
1633
+ *
1634
+ * `recorded: false` is the whole point: every number here is an *absence of
1635
+ * data*, not a measured zero, and a reader that treats the difference
1636
+ * seriously must be able to tell them apart. Built fresh per call so a caller
1637
+ * that mutates `days` or `models` cannot poison the next reader.
1638
+ */
1639
+ function unrecordedSummary() {
1640
+ return {
1641
+ date: utcDay(),
1642
+ todayCostUsd: 0,
1643
+ todayTurns: 0,
1644
+ todayModels: {},
1645
+ totalCostUsd: 0,
1646
+ models: {},
1647
+ retentionDays: 90,
1648
+ recorded: false,
1649
+ days: [],
1650
+ months: []
1651
+ };
1652
+ }
1653
+ /**
1654
+ * The app's read-only window onto its own spend, in-process like `ctx.state`.
1655
+ *
1656
+ * The app name is closed over here and never taken as an argument, so an app
1657
+ * can only ever read its own ledger: there is no parameter to point at another
1658
+ * app, no HTTP hop to spoof, and nothing on this surface that writes. The
1659
+ * recorder's own reads already filter to the retention window, so what an app
1660
+ * sees is exactly what the host kept.
1661
+ *
1662
+ * Without a recorder nothing is metered, so the honest answer is "no data":
1663
+ * `turns()` is empty and `summary()` reports `recorded: false` rather than a
1664
+ * fabricated zero.
1665
+ */
1666
+ function appUsage(name, usage) {
1667
+ if (usage === void 0) return {
1668
+ turns: () => Promise.resolve([]),
1669
+ summary: () => Promise.resolve(unrecordedSummary())
1670
+ };
1671
+ return {
1672
+ turns: (query) => usage.turns(name, query),
1673
+ summary: () => usage.summary(name)
1674
+ };
1675
+ }
1676
+ /**
1416
1677
  * Reads and parses the manifest-declared agent files. The agent's name is
1417
1678
  * the filename without the `.md` extension.
1418
1679
  */
@@ -1450,10 +1711,13 @@ async function resolveAppConfig(store, manifest) {
1450
1711
  * while required keys are missing the scoped provider fails closed.
1451
1712
  *
1452
1713
  * When a `usage` recorder is supplied, every session opened through the
1453
- * returned context reports its per-turn cost to it, and the app's host-set
1454
- * daily spend cap (if any) is enforced on every createSession.
1714
+ * returned context reports its per-turn cost to it, the app's host-set daily
1715
+ * spend cap (if any) is enforced on every createSession, and `ctx.usage` reads
1716
+ * back the app's own ledger. Without one there is no metering, no cap, and
1717
+ * `ctx.usage` answers "nothing recorded" — the member is required by the
1718
+ * contract, so it is always present.
1455
1719
  */
1456
- async function buildAppContext(store, manifest, provider, usage) {
1720
+ async function buildAppContext(store, manifest, provider, usage, approvals) {
1457
1721
  const name = manifest.name;
1458
1722
  const dataDir = store.appDataDir(name);
1459
1723
  const repoDir = store.appRepoDir(name);
@@ -1470,6 +1734,11 @@ async function buildAppContext(store, manifest, provider, usage) {
1470
1734
  const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
1471
1735
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1472
1736
  const logger = createLogger(name);
1737
+ let askTools = manifest.permissions.askTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1738
+ if (askTools.length > 0 && provider.supportsApprovals !== true) {
1739
+ logger.warn(`AI engine "${provider.name}" does not support interactive approvals; ask-tier tools are disabled`, { askTools });
1740
+ askTools = [];
1741
+ }
1473
1742
  const recordUsage = usage ? async (turn) => {
1474
1743
  try {
1475
1744
  await usage.record(name, turn);
@@ -1483,9 +1752,15 @@ async function buildAppContext(store, manifest, provider, usage) {
1483
1752
  config,
1484
1753
  logger,
1485
1754
  state: new JsonFileStore(join(dataDir, "state.json")),
1755
+ usage: appUsage(name, usage),
1486
1756
  ai: scopeProvider(provider, {
1487
1757
  mcpServers,
1488
1758
  allowedTools,
1759
+ askTools,
1760
+ ...askTools.length > 0 && {
1761
+ readGrants: async () => (await store.readGrants(name)).tools,
1762
+ ...approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) }
1763
+ },
1489
1764
  agents: await loadAgents(repoDir, manifest.agents),
1490
1765
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
1491
1766
  ...instructions !== void 0 && { instructions },
@@ -1496,6 +1771,253 @@ async function buildAppContext(store, manifest, provider, usage) {
1496
1771
  })
1497
1772
  };
1498
1773
  }
1774
+ const APPROVAL_LOG_CAP = 100;
1775
+ /** Settled records kept in memory so a late responder gets 409, not 404. */
1776
+ const SETTLED_CAP = 200;
1777
+ /**
1778
+ * Host-wide bookkeeping for interactive tool approvals. Sessions wrapped by
1779
+ * `superviseSession` register every `approval_required` here, so chat turns
1780
+ * and cron jobs alike show up in one pending list; decisions flow back through
1781
+ * `respond()` to the provider, and every outcome is appended to the app's
1782
+ * `approvals.json` log. The registry also arms the host's timeout policy:
1783
+ * a request nobody decides is denied after {@link DEFAULT_APPROVAL_TIMEOUT_MS}
1784
+ * (overridable via `approvalTimeoutSeconds` in the global config — host
1785
+ * policy, like budgets).
1786
+ *
1787
+ * Always-allow grants (`grants.json`) are written here too, so ask→allow
1788
+ * promotion stays inside manifest bounds in one place.
1789
+ */
1790
+ var ApprovalRegistry = class {
1791
+ #store;
1792
+ #timeoutMs;
1793
+ #pending = /* @__PURE__ */ new Map();
1794
+ #settled = /* @__PURE__ */ new Map();
1795
+ /** Serializes approvals.json read-modify-write across apps and timers. */
1796
+ #logQueue = Promise.resolve();
1797
+ constructor(store, options = {}) {
1798
+ this.#store = store;
1799
+ this.#timeoutMs = options.timeoutMs ?? 3e5;
1800
+ }
1801
+ /** Applies the host-config timeout override; safe to fire-and-forget at boot. */
1802
+ async init() {
1803
+ const seconds = (await this.#store.readGlobalConfig()).approvalTimeoutSeconds;
1804
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) this.#timeoutMs = seconds * 1e3;
1805
+ }
1806
+ /** Registers a provider-born approval request and arms the auto-deny timer. */
1807
+ track(app, event, respond) {
1808
+ if (this.#pending.has(event.approvalId)) return;
1809
+ const now = Date.now();
1810
+ const record = {
1811
+ approvalId: event.approvalId,
1812
+ app,
1813
+ tool: event.tool,
1814
+ input: event.input,
1815
+ requestedAt: new Date(now).toISOString(),
1816
+ expiresAt: new Date(now + this.#timeoutMs).toISOString(),
1817
+ status: "pending"
1818
+ };
1819
+ const timer = setTimeout(() => this.#timeout(event.approvalId), this.#timeoutMs);
1820
+ timer.unref?.();
1821
+ this.#pending.set(event.approvalId, {
1822
+ record,
1823
+ respond,
1824
+ timer
1825
+ });
1826
+ this.#mutateLog(app, (approvals) => approvals.push({ ...record }));
1827
+ }
1828
+ /** Attaches the conversation a chat-born approval belongs to (cron ones have none). */
1829
+ annotate(approvalId, fields) {
1830
+ const pending = this.#pending.get(approvalId);
1831
+ if (!pending) return;
1832
+ pending.record.conversationId = fields.conversationId;
1833
+ this.#updateLog(pending.record);
1834
+ }
1835
+ /** The pending or settled record, for endpoint lookups. */
1836
+ get(approvalId) {
1837
+ return this.#pending.get(approvalId)?.record ?? this.#settled.get(approvalId);
1838
+ }
1839
+ /** Snapshots of pending records, optionally narrowed; newest last. */
1840
+ pending(filter = {}) {
1841
+ const records = [];
1842
+ for (const { record } of this.#pending.values()) {
1843
+ if (filter.app !== void 0 && record.app !== filter.app) continue;
1844
+ if (filter.conversationId !== void 0 && record.conversationId !== filter.conversationId) continue;
1845
+ records.push({ ...record });
1846
+ }
1847
+ return records;
1848
+ }
1849
+ /**
1850
+ * Applies a user decision: settles the record, then forwards the decision to
1851
+ * the session that is suspended on it. Returns 'unknown' for an id this host
1852
+ * never saw and 'resolved' for one already decided, so the endpoint can tell
1853
+ * 404 from 409.
1854
+ */
1855
+ respond(approvalId, decision) {
1856
+ const pending = this.#pending.get(approvalId);
1857
+ if (!pending) return this.#settled.has(approvalId) ? "resolved" : "unknown";
1858
+ this.#settle(pending, decision.behavior === "allow" ? "allowed" : "denied", decision.behavior === "allow" ? decision.scope : void 0);
1859
+ pending.respond(decision).catch(() => void 0);
1860
+ return "ok";
1861
+ }
1862
+ /**
1863
+ * Observes a provider-side resolution (session closed, interrupt) arriving
1864
+ * through the event stream. A registry-initiated decision already settled
1865
+ * the record, so this is a no-op for it.
1866
+ */
1867
+ resolved(event) {
1868
+ const pending = this.#pending.get(event.approvalId);
1869
+ if (!pending) return;
1870
+ const status = event.behavior === "allow" ? "allowed" : event.reason === "timeout" ? "timeout" : "denied";
1871
+ this.#settle(pending, status);
1872
+ }
1873
+ /**
1874
+ * Marks an approval whose session's event stream ended without a resolution
1875
+ * (crash, abrupt close) as denied, so nothing stays pending forever.
1876
+ */
1877
+ abandon(approvalId) {
1878
+ const pending = this.#pending.get(approvalId);
1879
+ if (!pending) return;
1880
+ this.#settle(pending, "denied");
1881
+ }
1882
+ /** Reads the app's always-allow grants. */
1883
+ async grants(app) {
1884
+ return (await this.#store.readGrants(app)).tools;
1885
+ }
1886
+ /**
1887
+ * Persists an always-allow grant. Validated against the manifest's askTools:
1888
+ * a grant can promote ask→allow but never allow a tool the manifest did not
1889
+ * declare askable.
1890
+ */
1891
+ async addGrant(app, tool, askTools) {
1892
+ if (!askTools.includes(tool)) throw new AppSdkError(`Tool "${tool}" is not an ask-tier tool of app "${app}"`);
1893
+ const state = await this.#store.readGrants(app);
1894
+ if (state.tools.includes(tool)) return;
1895
+ state.tools.push(tool);
1896
+ await this.#store.writeGrants(app, state);
1897
+ }
1898
+ /** Clears timers and denies everything still pending (host shutdown). */
1899
+ close() {
1900
+ for (const approvalId of [...this.#pending.keys()]) this.abandon(approvalId);
1901
+ }
1902
+ #timeout(approvalId) {
1903
+ const pending = this.#pending.get(approvalId);
1904
+ if (!pending) return;
1905
+ this.#settle(pending, "timeout");
1906
+ pending.respond({
1907
+ behavior: "deny",
1908
+ message: "Approval timed out",
1909
+ reason: "timeout"
1910
+ }).catch(() => void 0);
1911
+ }
1912
+ #settle(pending, status, scope) {
1913
+ clearTimeout(pending.timer);
1914
+ this.#pending.delete(pending.record.approvalId);
1915
+ pending.record.status = status;
1916
+ pending.record.decidedAt = (/* @__PURE__ */ new Date()).toISOString();
1917
+ if (scope !== void 0) pending.record.scope = scope;
1918
+ this.#settled.set(pending.record.approvalId, pending.record);
1919
+ if (this.#settled.size > SETTLED_CAP) {
1920
+ const oldest = this.#settled.keys().next().value;
1921
+ if (oldest !== void 0) this.#settled.delete(oldest);
1922
+ }
1923
+ this.#updateLog(pending.record);
1924
+ }
1925
+ #updateLog(record) {
1926
+ this.#mutateLog(record.app, (approvals) => {
1927
+ const index = approvals.findIndex((entry) => entry.approvalId === record.approvalId);
1928
+ if (index === -1) approvals.push({ ...record });
1929
+ else approvals[index] = { ...record };
1930
+ });
1931
+ }
1932
+ #mutateLog(app, mutate) {
1933
+ const update = this.#logQueue.then(async () => {
1934
+ const log = await this.#store.readApprovalLog(app);
1935
+ mutate(log.approvals);
1936
+ if (log.approvals.length > APPROVAL_LOG_CAP) log.approvals.splice(0, log.approvals.length - APPROVAL_LOG_CAP);
1937
+ await this.#store.writeApprovalLog(app, log);
1938
+ });
1939
+ this.#logQueue = update.catch(() => void 0);
1940
+ }
1941
+ /** Resolves when all queued approvals.json writes have landed (tests). */
1942
+ flush() {
1943
+ return this.#logQueue.then(() => void 0);
1944
+ }
1945
+ };
1946
+ //#endregion
1947
+ //#region src/chat-bus.ts
1948
+ const RING_CAP = 200;
1949
+ /**
1950
+ * Per-conversation event bus: POST /chat enqueues a turn whose events are
1951
+ * published here, and any number of SSE subscribers replay then follow them.
1952
+ * Ids are monotonic per bus; the ring keeps the last {@link RING_CAP} entries
1953
+ * so a reconnecting subscriber can resume from its Last-Event-ID.
1954
+ */
1955
+ var ConversationBus = class {
1956
+ #nextId = 1;
1957
+ #ring = [];
1958
+ #wakers = [];
1959
+ publish(event) {
1960
+ const entry = {
1961
+ id: this.#nextId++,
1962
+ event
1963
+ };
1964
+ this.#ring.push(entry);
1965
+ if (this.#ring.length > RING_CAP) this.#ring.shift();
1966
+ for (const wake of this.#wakers.splice(0)) wake();
1967
+ return entry.id;
1968
+ }
1969
+ /**
1970
+ * Replay then live. With a `lastEventId` still covered by the ring, replay
1971
+ * resumes right after it; one that scrolled out yields a `resync` first.
1972
+ * Without one (a fresh subscriber), replay starts at the current turn — the
1973
+ * entry after the newest `result` — because everything before it is already
1974
+ * served by the history endpoint and would render twice.
1975
+ *
1976
+ * The iterator only ends via the signal (client disconnect); the bus itself
1977
+ * never closes.
1978
+ */
1979
+ async *subscribe(lastEventId, signal) {
1980
+ let cursor;
1981
+ if (lastEventId === void 0) cursor = this.#turnStart();
1982
+ else {
1983
+ const oldest = this.#ring[0]?.id;
1984
+ if (oldest !== void 0 && lastEventId < oldest - 1) {
1985
+ yield {
1986
+ id: this.#nextId - 1,
1987
+ event: { type: "resync" }
1988
+ };
1989
+ cursor = this.#turnStart();
1990
+ } else cursor = lastEventId + 1;
1991
+ }
1992
+ while (!signal?.aborted) {
1993
+ const next = this.#ring.find((entry) => entry.id >= cursor);
1994
+ if (next) {
1995
+ cursor = next.id + 1;
1996
+ yield next;
1997
+ continue;
1998
+ }
1999
+ await this.#nextPublish(signal);
2000
+ }
2001
+ }
2002
+ /** The id right after the newest `result` in the ring — the in-flight turn's start. */
2003
+ #turnStart() {
2004
+ for (let i = this.#ring.length - 1; i >= 0; i--) {
2005
+ const entry = this.#ring[i];
2006
+ if (entry !== void 0 && entry.event.type === "result") return entry.id + 1;
2007
+ }
2008
+ return this.#ring[0]?.id ?? this.#nextId;
2009
+ }
2010
+ #nextPublish(signal) {
2011
+ return new Promise((resolve) => {
2012
+ const onAbort = () => resolve();
2013
+ this.#wakers.push(() => {
2014
+ signal?.removeEventListener("abort", onAbort);
2015
+ resolve();
2016
+ });
2017
+ signal?.addEventListener("abort", onAbort, { once: true });
2018
+ });
2019
+ }
2020
+ };
1499
2021
  //#endregion
1500
2022
  //#region src/scheduler.ts
1501
2023
  const RUN_LOG_CAP = 50;
@@ -2136,14 +2658,32 @@ function appErrorPage(name, err) {
2136
2658
  * GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
2137
2659
  * per-model token totals, monthly rollups,
2138
2660
  * `recorded`, and the budget status
2661
+ * GET /api/apps/:name/turns the per-turn ledger that summary folds: `{ turns }`,
2662
+ * oldest first, each with `at`, `costUsd`, and the
2663
+ * session/model attribution the buckets drop.
2664
+ * ?since=<timestamp> (inclusive) and ?session=<id>
2665
+ * narrow it, ANDed; an unreadable `since` is a 400
2139
2666
  * GET /api/apps/:name/budget today's spend against the app's daily cap,
2140
2667
  * plus `recorded`
2141
2668
  * PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
2142
2669
  * (a host setting; apps cannot set their own)
2143
- * POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
2144
- * (omit conversationId to start a new conversation; the
2145
- * id is echoed in the x-conversation-id response header;
2146
- * 402 when the app is over its daily spend cap)
2670
+ * POST /api/apps/:name/chat { message, conversationId? } -> 202 { conversationId };
2671
+ * the turn runs detached and its AgentEvents are published
2672
+ * on the conversation's event bus (omit conversationId to
2673
+ * start a new conversation; the id is also echoed in the
2674
+ * x-conversation-id response header; 402 when the app is
2675
+ * over its daily spend cap)
2676
+ * GET /api/apps/:name/chat/:conversationId/events standing SSE from the conversation
2677
+ * bus: replay from Last-Event-ID (or ?lastEventId=), then
2678
+ * live; a scrolled-out id gets a `resync` event first
2679
+ * GET /api/apps/:name/chat/:conversationId/pending-approvals reconnect snapshot of
2680
+ * undecided tool approvals for this conversation
2681
+ * POST /api/apps/:name/chat/:conversationId/approvals/:approvalId
2682
+ * { behavior: "allow"|"deny", scope?, message? } -> decide a
2683
+ * pending approval (scope "always" persists a grant);
2684
+ * 404 unknown/expired, 409 already decided
2685
+ * GET /api/approvals host-wide pending tool approvals (chat and cron alike)
2686
+ * POST /api/approvals/:approvalId decide a pending approval regardless of source
2147
2687
  * GET /api/apps/:name/chat/conversations conversations, most recent first
2148
2688
  * POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
2149
2689
  * optionally adopting an existing provider session (e.g. an
@@ -2168,9 +2708,18 @@ function createServer(store, provider, options = {}) {
2168
2708
  const apps = /* @__PURE__ */ new Map();
2169
2709
  const sessions = /* @__PURE__ */ new Map();
2170
2710
  const usage = new UsageRecorder(store);
2711
+ const approvals = new ApprovalRegistry(store);
2712
+ approvals.init().catch((err) => {
2713
+ console.warn(`alashi: failed to read approval timeout config: ${String(err)}`);
2714
+ });
2715
+ store.listInstalled().then((records) => Promise.all(records.map((record) => usage.compact(record.name).catch((err) => {
2716
+ console.warn(`alashi: failed to compact turns.jsonl for "${record.name}": ${String(err)}`);
2717
+ })))).catch((err) => {
2718
+ console.warn(`alashi: failed to compact turn ledgers: ${String(err)}`);
2719
+ });
2171
2720
  async function startApp(name) {
2172
2721
  const { definition, manifest } = await loadApp(store.appRepoDir(name));
2173
- const ctx = await buildAppContext(store, manifest, provider, usage);
2722
+ const ctx = await buildAppContext(store, manifest, provider, usage, approvals);
2174
2723
  return {
2175
2724
  manifest,
2176
2725
  ctx,
@@ -2215,6 +2764,28 @@ function createServer(store, provider, options = {}) {
2215
2764
  }
2216
2765
  return session;
2217
2766
  }
2767
+ /** One bus per conversation; created on first use, kept for reconnects. */
2768
+ const buses = /* @__PURE__ */ new Map();
2769
+ function getBus(name, conversationId) {
2770
+ const key = `${name}/${conversationId}`;
2771
+ let bus = buses.get(key);
2772
+ if (!bus) {
2773
+ bus = new ConversationBus();
2774
+ buses.set(key, bus);
2775
+ }
2776
+ return bus;
2777
+ }
2778
+ /**
2779
+ * Per-conversation turn FIFO: POST /chat enqueues and returns 202, the
2780
+ * detached runner drains one send() at a time (providers reject concurrent
2781
+ * turns on one session). Runners publish their own failures to the bus, so
2782
+ * a queued promise never rejects into the void.
2783
+ */
2784
+ const turnQueues = /* @__PURE__ */ new Map();
2785
+ function enqueueTurn(key, run) {
2786
+ const next = (turnQueues.get(key) ?? Promise.resolve()).then(run);
2787
+ turnQueues.set(key, next.catch(() => void 0));
2788
+ }
2218
2789
  const scheduler = new Scheduler(store, getApp);
2219
2790
  /** Rebuilds the request with `prefix` stripped and hands it to the app's handler. */
2220
2791
  function dispatch(handler, raw, prefix) {
@@ -2329,6 +2900,22 @@ ${rows}
2329
2900
  </tbody>
2330
2901
  <tfoot><tr><td colspan="4" class="muted" title="${escapeHtml(TODAY_COLUMN_TITLE)}">AI spend today (UTC)</td><td class="muted" style="text-align:right">${totalToday}</td><td></td></tr></tfoot>
2331
2902
  </table>`;
2903
+ const pendingApprovals = approvals.pending();
2904
+ const approvalsSection = pendingApprovals.length === 0 ? "" : `<h2>Pending tool approvals</h2>
2905
+ <div class="panel">
2906
+ <table>
2907
+ <thead><tr><th>App</th><th>Tool</th><th>Requested</th><th>Expires</th><th>Where</th></tr></thead>
2908
+ <tbody>
2909
+ ${pendingApprovals.map((approval) => `<tr>
2910
+ <td><a href="/app/${escapeHtml(approval.app)}/">${escapeHtml(approval.app)}</a></td>
2911
+ <td><code>${escapeHtml(approval.tool)}</code></td>
2912
+ <td class="muted">${escapeHtml(approval.requestedAt)}</td>
2913
+ <td class="muted">${escapeHtml(approval.expiresAt)}</td>
2914
+ <td class="muted">${approval.conversationId ? "chat" : "background"}</td>
2915
+ </tr>`).join("\n")}
2916
+ </tbody>
2917
+ </table>
2918
+ </div>`;
2332
2919
  return c.html(`<!doctype html>
2333
2920
  <html lang="en">
2334
2921
  <head>
@@ -2345,6 +2932,7 @@ ${rows}
2345
2932
  <div class="panel">
2346
2933
  ${body}
2347
2934
  </div>
2935
+ ${approvalsSection}
2348
2936
  </main>
2349
2937
  </body>
2350
2938
  </html>
@@ -2390,6 +2978,33 @@ ${body}
2390
2978
  budget: budgetStatusFor(summary, appBudget(config, name))
2391
2979
  });
2392
2980
  });
2981
+ /**
2982
+ * The per-turn ledger the usage summary is a fold over: one entry per metered
2983
+ * turn, oldest first, carrying the session attribution the day/month buckets
2984
+ * throw away. `?since=` and `?session=` are optional and ANDed.
2985
+ *
2986
+ * `since` is normalised to a canonical ISO instant before it reaches the
2987
+ * recorder, whose bound is compared lexically against the stored `at`. That
2988
+ * keeps a bare `2026-03-02` working as "from that day on" while making an
2989
+ * offset-bearing timestamp (`...T12:00+05:30`) mean the instant it names
2990
+ * rather than whatever its digits happen to sort next to.
2991
+ */
2992
+ app.get("/api/apps/:name/turns", async (c) => {
2993
+ const name = c.req.param("name");
2994
+ if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
2995
+ const rawSince = c.req.query("since");
2996
+ let since;
2997
+ if (rawSince !== void 0) {
2998
+ const parsed = Date.parse(rawSince);
2999
+ if (Number.isNaN(parsed)) return c.json({ error: `"since" must be a timestamp, got "${rawSince}"` }, 400);
3000
+ since = new Date(parsed).toISOString();
3001
+ }
3002
+ const sessionId = c.req.query("session");
3003
+ return c.json({ turns: await usage.turns(name, {
3004
+ ...since !== void 0 && { since },
3005
+ ...sessionId !== void 0 && { sessionId }
3006
+ }) });
3007
+ });
2393
3008
  app.get("/api/apps/:name/budget", async (c) => {
2394
3009
  const name = c.req.param("name");
2395
3010
  if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -2611,8 +3226,12 @@ ${body}
2611
3226
  } catch (err) {
2612
3227
  return c.json({ error: `Failed to start AI session: ${String(err)}` }, 502);
2613
3228
  }
2614
- c.header("x-conversation-id", conversation.id);
2615
- return streamSSE(c, async (stream) => {
3229
+ const bus = getBus(name, conversation.id);
3230
+ bus.publish({
3231
+ type: "user_message",
3232
+ text: message
3233
+ });
3234
+ enqueueTurn(`${name}/${conversation.id}`, async () => {
2616
3235
  try {
2617
3236
  for await (const event of session.send(message)) {
2618
3237
  if (event.type === "session") {
@@ -2620,23 +3239,105 @@ ${body}
2620
3239
  conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2621
3240
  await store.writeChatState(name, state);
2622
3241
  }
2623
- await stream.writeSSE({
2624
- event: event.type,
2625
- data: JSON.stringify(event)
2626
- });
3242
+ if (event.type === "approval_required") approvals.annotate(event.approvalId, { conversationId: conversation.id });
3243
+ bus.publish(event);
2627
3244
  }
2628
3245
  } catch (err) {
2629
- await stream.writeSSE({
2630
- event: "result",
2631
- data: JSON.stringify({
2632
- type: "result",
2633
- ok: false,
2634
- error: String(err)
2635
- })
3246
+ bus.publish({
3247
+ type: "result",
3248
+ ok: false,
3249
+ error: String(err)
2636
3250
  });
2637
3251
  }
2638
3252
  });
3253
+ c.header("x-conversation-id", conversation.id);
3254
+ return c.json({ conversationId: conversation.id }, 202);
3255
+ });
3256
+ app.get("/api/apps/:name/chat/:conversationId/events", async (c) => {
3257
+ const name = c.req.param("name");
3258
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3259
+ const conversationId = c.req.param("conversationId");
3260
+ const { conversations } = await getChatState(name);
3261
+ if (!conversations.some((conv) => conv.id === conversationId)) return c.json({ error: "Unknown conversation" }, 404);
3262
+ const raw = c.req.header("last-event-id") ?? c.req.query("lastEventId");
3263
+ const lastEventId = raw !== void 0 && /^\d+$/.test(raw) ? Number(raw) : void 0;
3264
+ const bus = getBus(name, conversationId);
3265
+ const signal = c.req.raw.signal;
3266
+ return streamSSE(c, async (stream) => {
3267
+ for await (const entry of bus.subscribe(lastEventId, signal)) await stream.writeSSE({
3268
+ id: String(entry.id),
3269
+ data: JSON.stringify(entry.event)
3270
+ });
3271
+ });
3272
+ });
3273
+ app.get("/api/apps/:name/chat/:conversationId/pending-approvals", async (c) => {
3274
+ const name = c.req.param("name");
3275
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3276
+ return c.json({ approvals: approvals.pending({
3277
+ app: name,
3278
+ conversationId: c.req.param("conversationId")
3279
+ }) });
3280
+ });
3281
+ /** Parses an approval decision body; returns undefined (with a 400 sent) when invalid. */
3282
+ function parseDecision(body) {
3283
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
3284
+ const { behavior, scope, message } = body;
3285
+ if (behavior === "allow") {
3286
+ if (scope !== void 0 && scope !== "once" && scope !== "session" && scope !== "always") return;
3287
+ return {
3288
+ behavior: "allow",
3289
+ ...scope !== void 0 && { scope }
3290
+ };
3291
+ }
3292
+ if (behavior === "deny") {
3293
+ if (message !== void 0 && typeof message !== "string") return void 0;
3294
+ return {
3295
+ behavior: "deny",
3296
+ ...message !== void 0 && { message },
3297
+ reason: "user"
3298
+ };
3299
+ }
3300
+ }
3301
+ /**
3302
+ * Decides one pending approval. `expect` narrows which records this route
3303
+ * may touch (the conversation-scoped route cannot decide another app's or
3304
+ * conversation's approvals).
3305
+ */
3306
+ async function decideApproval(c, approvalId, expect) {
3307
+ let body;
3308
+ try {
3309
+ body = await c.req.json();
3310
+ } catch {
3311
+ return c.json({ error: "Body must be JSON" }, 400);
3312
+ }
3313
+ const decision = parseDecision(body);
3314
+ if (!decision) return c.json({ error: "Body must be { \"behavior\": \"allow\"|\"deny\", \"scope\"?, \"message\"? }" }, 400);
3315
+ const record = approvals.get(approvalId);
3316
+ if (!record || expect !== void 0 && (record.app !== expect.app || expect.conversationId !== void 0 && record.conversationId !== expect.conversationId)) return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
3317
+ if (decision.behavior === "allow" && decision.scope === "always") {
3318
+ const running = await getApp(record.app);
3319
+ if (running) try {
3320
+ await approvals.addGrant(record.app, record.tool, running.manifest.permissions.askTools);
3321
+ } catch (err) {
3322
+ return c.json({ error: String(err) }, 400);
3323
+ }
3324
+ }
3325
+ const outcome = approvals.respond(approvalId, decision);
3326
+ if (outcome === "unknown") return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
3327
+ if (outcome === "resolved") return c.json({ error: `Approval "${approvalId}" is already decided` }, 409);
3328
+ return c.json({ approval: approvals.get(approvalId) });
3329
+ }
3330
+ app.post("/api/apps/:name/chat/:conversationId/approvals/:approvalId", async (c) => {
3331
+ const name = c.req.param("name");
3332
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3333
+ return decideApproval(c, c.req.param("approvalId"), {
3334
+ app: name,
3335
+ conversationId: c.req.param("conversationId")
3336
+ });
2639
3337
  });
3338
+ /** Host-wide views: cron/app-route approvals have no conversation to scope by. */
3339
+ app.get("/api/approvals", (c) => c.json({ approvals: approvals.pending() }));
3340
+ app.post("/api/approvals/:approvalId", (c) => decideApproval(c, c.req.param("approvalId")));
2640
3341
  app.all("/app/:name/api/*", async (c) => {
2641
3342
  const name = c.req.param("name");
2642
3343
  const running = await getApp(name);
@@ -2668,6 +3369,7 @@ ${body}
2668
3369
  auth,
2669
3370
  async close() {
2670
3371
  scheduler.stop();
3372
+ approvals.close();
2671
3373
  const open = [...sessions.values()];
2672
3374
  sessions.clear();
2673
3375
  await Promise.all(open.map(async (p) => {
@@ -2739,9 +3441,18 @@ var Store = class {
2739
3441
  appJobStatePath(name) {
2740
3442
  return join(this.appDir(name), "jobs.json");
2741
3443
  }
3444
+ appApprovalLogPath(name) {
3445
+ return join(this.appDir(name), "approvals.json");
3446
+ }
3447
+ appGrantsPath(name) {
3448
+ return join(this.appDir(name), "grants.json");
3449
+ }
2742
3450
  appUsagePath(name) {
2743
3451
  return join(this.appDir(name), "usage.json");
2744
3452
  }
3453
+ appTurnsPath(name) {
3454
+ return join(this.appDir(name), "turns.jsonl");
3455
+ }
2745
3456
  registryCachePath(name) {
2746
3457
  if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
2747
3458
  return join(this.registriesDir, `${name}.json`);
@@ -2770,6 +3481,18 @@ var Store = class {
2770
3481
  async writeChatState(name, state) {
2771
3482
  await writeJson(this.appChatStatePath(name), state);
2772
3483
  }
3484
+ async readApprovalLog(name) {
3485
+ return { approvals: (await readJson(this.appApprovalLogPath(name)))?.approvals ?? [] };
3486
+ }
3487
+ async writeApprovalLog(name, log) {
3488
+ await writeJson(this.appApprovalLogPath(name), log);
3489
+ }
3490
+ async readGrants(name) {
3491
+ return { tools: (await readJson(this.appGrantsPath(name)))?.tools ?? [] };
3492
+ }
3493
+ async writeGrants(name, state) {
3494
+ await writeJson(this.appGrantsPath(name), state);
3495
+ }
2773
3496
  async readJobState(name) {
2774
3497
  const state = await readJson(this.appJobStatePath(name));
2775
3498
  return {
@@ -2790,6 +3513,51 @@ var Store = class {
2790
3513
  async writeUsageState(name, state) {
2791
3514
  await writeJson(this.appUsagePath(name), state);
2792
3515
  }
3516
+ /** Appends one line to the app's turn ledger, creating the container if needed. */
3517
+ async appendTurnRecord(name, record) {
3518
+ const path = this.appTurnsPath(name);
3519
+ await mkdir(join(path, ".."), { recursive: true });
3520
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
3521
+ }
3522
+ /**
3523
+ * Every parseable line of the app's turn ledger, in file order (oldest
3524
+ * first); an absent file reads as no turns.
3525
+ *
3526
+ * An append-only log written across crashes and hand edits will eventually
3527
+ * contain a truncated or malformed line. One bad line must cost only itself,
3528
+ * so unparseable lines — and lines without an `at` timestamp, which nothing
3529
+ * downstream could place in time — are skipped rather than thrown on.
3530
+ */
3531
+ async readTurnRecords(name) {
3532
+ let content;
3533
+ try {
3534
+ content = await readFile(this.appTurnsPath(name), "utf8");
3535
+ } catch (err) {
3536
+ if (err.code === "ENOENT") return [];
3537
+ throw err;
3538
+ }
3539
+ const records = [];
3540
+ for (const line of content.split("\n")) {
3541
+ if (line.trim() === "") continue;
3542
+ let parsed;
3543
+ try {
3544
+ parsed = JSON.parse(line);
3545
+ } catch {
3546
+ continue;
3547
+ }
3548
+ const record = parsed;
3549
+ if (typeof record?.at === "string") records.push(record);
3550
+ }
3551
+ return records;
3552
+ }
3553
+ /** Replaces the app's turn ledger atomically (tmp file + rename), for compaction. */
3554
+ async writeTurnRecords(name, records) {
3555
+ const path = this.appTurnsPath(name);
3556
+ await mkdir(join(path, ".."), { recursive: true });
3557
+ const tmp = `${path}.tmp`;
3558
+ await writeFile(tmp, records.map((record) => `${JSON.stringify(record)}\n`).join(""), "utf8");
3559
+ await rename(tmp, path);
3560
+ }
2793
3561
  get authStatePath() {
2794
3562
  return join(this.root, "auth.json");
2795
3563
  }
@@ -2823,4 +3591,4 @@ var Store = class {
2823
3591
  //#endregion
2824
3592
  export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, 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 };
2825
3593
 
2826
- //# sourceMappingURL=store-DoAGOynU.mjs.map
3594
+ //# sourceMappingURL=store-dipRcfKX.mjs.map