@alashi/cli 0.14.0 → 0.16.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.
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
- import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
3
+ import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, compilePolicy, describeRule, evaluateAppConfig, evaluatePolicy, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings, permissionsPolicy } 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,81 @@ 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) }
1585
+ };
1586
+ }
1587
+ /** Runs `onClose` once when the session closes (drops its gateway grants). */
1588
+ function endOnClose(session, onClose) {
1589
+ return {
1590
+ get id() {
1591
+ return session.id;
1592
+ },
1593
+ send: (input) => session.send(input),
1594
+ interrupt: () => session.interrupt(),
1595
+ close: async () => {
1596
+ onClose();
1597
+ await session.close();
1598
+ },
1599
+ ...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
1378
1600
  };
1379
1601
  }
1380
1602
  /**
@@ -1393,26 +1615,104 @@ function scopeProvider(provider, defaults) {
1393
1615
  ...defaults.mcpServers,
1394
1616
  ...options.mcpServers
1395
1617
  };
1396
- const allowedTools = [.../* @__PURE__ */ new Set([...defaults.allowedTools, ...options.allowedTools ?? []])];
1618
+ if (defaults.gateSession) {
1619
+ const { allowedTools: callAllowed, askTools: callAsk, ...rest } = options;
1620
+ const gate = defaults.gateSession({
1621
+ ...callAllowed !== void 0 && { allowedTools: callAllowed },
1622
+ ...callAsk !== void 0 && { askTools: callAsk }
1623
+ });
1624
+ const agents = {
1625
+ ...defaults.agents,
1626
+ ...rest.agents
1627
+ };
1628
+ const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...rest.skillDirs ?? []])];
1629
+ let session = await provider.createSession({
1630
+ cwd: defaults.cwd,
1631
+ ...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
1632
+ ...rest,
1633
+ ...Object.keys(mcpServers).length > 0 && { mcpServers },
1634
+ toolGate: gate.gate,
1635
+ ...Object.keys(agents).length > 0 && { agents },
1636
+ ...skillDirs.length > 0 && { skillDirs }
1637
+ });
1638
+ session = endOnClose(session, gate.end);
1639
+ if (defaults.supervise) session = defaults.supervise(session);
1640
+ return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
1641
+ }
1642
+ const granted = (defaults.readGrants ? await defaults.readGrants() : []).filter((tool) => defaults.askTools.includes(tool));
1643
+ const allowedTools = [.../* @__PURE__ */ new Set([
1644
+ ...defaults.allowedTools,
1645
+ ...granted,
1646
+ ...options.allowedTools ?? []
1647
+ ])];
1648
+ const askTools = [.../* @__PURE__ */ new Set([...defaults.askTools.filter((tool) => !granted.includes(tool)), ...options.askTools ?? []])];
1397
1649
  const agents = {
1398
1650
  ...defaults.agents,
1399
1651
  ...options.agents
1400
1652
  };
1401
1653
  const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
1402
- const session = await provider.createSession({
1654
+ let session = await provider.createSession({
1403
1655
  cwd: defaults.cwd,
1404
1656
  ...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
1405
1657
  ...options,
1406
1658
  ...Object.keys(mcpServers).length > 0 && { mcpServers },
1407
1659
  ...allowedTools.length > 0 && { allowedTools },
1660
+ ...askTools.length > 0 && { askTools },
1408
1661
  ...Object.keys(agents).length > 0 && { agents },
1409
1662
  ...skillDirs.length > 0 && { skillDirs }
1410
1663
  });
1664
+ if (defaults.supervise) session = defaults.supervise(session);
1411
1665
  return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
1412
1666
  }
1413
1667
  };
1414
1668
  }
1415
1669
  /**
1670
+ * A summary that says "nothing has been metered", for a context built without
1671
+ * a recorder (tests, and callers that only need config and state).
1672
+ *
1673
+ * `recorded: false` is the whole point: every number here is an *absence of
1674
+ * data*, not a measured zero, and a reader that treats the difference
1675
+ * seriously must be able to tell them apart. Built fresh per call so a caller
1676
+ * that mutates `days` or `models` cannot poison the next reader.
1677
+ */
1678
+ function unrecordedSummary() {
1679
+ return {
1680
+ date: utcDay(),
1681
+ todayCostUsd: 0,
1682
+ todayTurns: 0,
1683
+ todayModels: {},
1684
+ totalCostUsd: 0,
1685
+ models: {},
1686
+ retentionDays: 90,
1687
+ recorded: false,
1688
+ days: [],
1689
+ months: []
1690
+ };
1691
+ }
1692
+ /**
1693
+ * The app's read-only window onto its own spend, in-process like `ctx.state`.
1694
+ *
1695
+ * The app name is closed over here and never taken as an argument, so an app
1696
+ * can only ever read its own ledger: there is no parameter to point at another
1697
+ * app, no HTTP hop to spoof, and nothing on this surface that writes. The
1698
+ * recorder's own reads already filter to the retention window, so what an app
1699
+ * sees is exactly what the host kept.
1700
+ *
1701
+ * Without a recorder nothing is metered, so the honest answer is "no data":
1702
+ * `turns()` is empty and `summary()` reports `recorded: false` rather than a
1703
+ * fabricated zero.
1704
+ */
1705
+ function appUsage(name, usage) {
1706
+ if (usage === void 0) return {
1707
+ turns: () => Promise.resolve([]),
1708
+ summary: () => Promise.resolve(unrecordedSummary())
1709
+ };
1710
+ return {
1711
+ turns: (query) => usage.turns(name, query),
1712
+ summary: () => usage.summary(name)
1713
+ };
1714
+ }
1715
+ /**
1416
1716
  * Reads and parses the manifest-declared agent files. The agent's name is
1417
1717
  * the filename without the `.md` extension.
1418
1718
  */
@@ -1450,10 +1750,13 @@ async function resolveAppConfig(store, manifest) {
1450
1750
  * while required keys are missing the scoped provider fails closed.
1451
1751
  *
1452
1752
  * 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.
1753
+ * returned context reports its per-turn cost to it, the app's host-set daily
1754
+ * spend cap (if any) is enforced on every createSession, and `ctx.usage` reads
1755
+ * back the app's own ledger. Without one there is no metering, no cap, and
1756
+ * `ctx.usage` answers "nothing recorded" — the member is required by the
1757
+ * contract, so it is always present.
1455
1758
  */
1456
- async function buildAppContext(store, manifest, provider, usage) {
1759
+ async function buildAppContext(store, manifest, provider, usage, approvals, gateway) {
1457
1760
  const name = manifest.name;
1458
1761
  const dataDir = store.appDataDir(name);
1459
1762
  const repoDir = store.appRepoDir(name);
@@ -1470,6 +1773,39 @@ async function buildAppContext(store, manifest, provider, usage) {
1470
1773
  const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
1471
1774
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1472
1775
  const logger = createLogger(name);
1776
+ let askTools = manifest.permissions.askTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1777
+ const agents = await loadAgents(repoDir, manifest.agents);
1778
+ const gated = gateway !== void 0 && provider.supportsToolGate === true && provider.supportsApprovals === true;
1779
+ const declaresPolicy = manifest.permissions.rules.length > 0 || manifest.permissions.default !== "deny";
1780
+ let gateSession;
1781
+ if (gated) {
1782
+ const base = permissionsPolicy({
1783
+ ...manifest.permissions,
1784
+ allowedTools,
1785
+ askTools
1786
+ });
1787
+ const implicit = Object.keys(agents).length > 0 ? [{
1788
+ id: "implicit.Agent",
1789
+ tool: "Agent",
1790
+ decision: "allow"
1791
+ }] : [];
1792
+ gateSession = (extra) => gateway.session(name, compilePolicy({
1793
+ default: base.default,
1794
+ rules: [
1795
+ ...base.rules,
1796
+ ...implicit,
1797
+ ...permissionsPolicy(extra).rules
1798
+ ]
1799
+ }));
1800
+ } else if (declaresPolicy) {
1801
+ logger.warn(`AI engine "${provider.name}" cannot enforce policy rules; all tools are disabled for this app`);
1802
+ allowedTools.length = 0;
1803
+ askTools = [];
1804
+ }
1805
+ if (!gated && askTools.length > 0 && provider.supportsApprovals !== true) {
1806
+ logger.warn(`AI engine "${provider.name}" does not support interactive approvals; ask-tier tools are disabled`, { askTools });
1807
+ askTools = [];
1808
+ }
1473
1809
  const recordUsage = usage ? async (turn) => {
1474
1810
  try {
1475
1811
  await usage.record(name, turn);
@@ -1483,10 +1819,15 @@ async function buildAppContext(store, manifest, provider, usage) {
1483
1819
  config,
1484
1820
  logger,
1485
1821
  state: new JsonFileStore(join(dataDir, "state.json")),
1822
+ usage: appUsage(name, usage),
1486
1823
  ai: scopeProvider(provider, {
1487
1824
  mcpServers,
1488
1825
  allowedTools,
1489
- agents: await loadAgents(repoDir, manifest.agents),
1826
+ askTools,
1827
+ ...gateSession !== void 0 && { gateSession },
1828
+ ...(gated || askTools.length > 0) && approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) },
1829
+ ...!gated && askTools.length > 0 && { readGrants: async () => (await store.readGrants(name)).tools },
1830
+ agents,
1490
1831
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
1491
1832
  ...instructions !== void 0 && { instructions },
1492
1833
  cwd: dataDir,
@@ -1496,6 +1837,255 @@ async function buildAppContext(store, manifest, provider, usage) {
1496
1837
  })
1497
1838
  };
1498
1839
  }
1840
+ const APPROVAL_LOG_CAP = 100;
1841
+ /** Settled records kept in memory so a late responder gets 409, not 404. */
1842
+ const SETTLED_CAP = 200;
1843
+ /**
1844
+ * Host-wide bookkeeping for interactive tool approvals. Sessions wrapped by
1845
+ * `superviseSession` register every `approval_required` here, so chat turns
1846
+ * and cron jobs alike show up in one pending list; decisions flow back through
1847
+ * `respond()` to the provider, and every outcome is appended to the app's
1848
+ * `approvals.json` log. The registry also arms the host's timeout policy:
1849
+ * a request nobody decides is denied after {@link DEFAULT_APPROVAL_TIMEOUT_MS}
1850
+ * (overridable via `approvalTimeoutSeconds` in the global config — host
1851
+ * policy, like budgets).
1852
+ *
1853
+ * Always-allow grants (`grants.json`) are written here too, so ask→allow
1854
+ * promotion stays inside manifest bounds in one place.
1855
+ */
1856
+ var ApprovalRegistry = class {
1857
+ #store;
1858
+ #timeoutMs;
1859
+ #pending = /* @__PURE__ */ new Map();
1860
+ #settled = /* @__PURE__ */ new Map();
1861
+ /** Serializes approvals.json read-modify-write across apps and timers. */
1862
+ #logQueue = Promise.resolve();
1863
+ constructor(store, options = {}) {
1864
+ this.#store = store;
1865
+ this.#timeoutMs = options.timeoutMs ?? 3e5;
1866
+ }
1867
+ /** Applies the host-config timeout override; safe to fire-and-forget at boot. */
1868
+ async init() {
1869
+ const seconds = (await this.#store.readGlobalConfig()).approvalTimeoutSeconds;
1870
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) this.#timeoutMs = seconds * 1e3;
1871
+ }
1872
+ /** Registers a provider-born approval request and arms the auto-deny timer. */
1873
+ track(app, event, respond) {
1874
+ if (this.#pending.has(event.approvalId)) return;
1875
+ const now = Date.now();
1876
+ const record = {
1877
+ approvalId: event.approvalId,
1878
+ app,
1879
+ tool: event.tool,
1880
+ input: event.input,
1881
+ requestedAt: new Date(now).toISOString(),
1882
+ expiresAt: new Date(now + this.#timeoutMs).toISOString(),
1883
+ status: "pending",
1884
+ ...event.ruleId !== void 0 && { ruleId: event.ruleId },
1885
+ ...event.reason !== void 0 && { reason: event.reason }
1886
+ };
1887
+ const timer = setTimeout(() => this.#timeout(event.approvalId), this.#timeoutMs);
1888
+ timer.unref?.();
1889
+ this.#pending.set(event.approvalId, {
1890
+ record,
1891
+ respond,
1892
+ timer
1893
+ });
1894
+ this.#mutateLog(app, (approvals) => approvals.push({ ...record }));
1895
+ }
1896
+ /** Attaches the conversation a chat-born approval belongs to (cron ones have none). */
1897
+ annotate(approvalId, fields) {
1898
+ const pending = this.#pending.get(approvalId);
1899
+ if (!pending) return;
1900
+ pending.record.conversationId = fields.conversationId;
1901
+ this.#updateLog(pending.record);
1902
+ }
1903
+ /** The pending or settled record, for endpoint lookups. */
1904
+ get(approvalId) {
1905
+ return this.#pending.get(approvalId)?.record ?? this.#settled.get(approvalId);
1906
+ }
1907
+ /** Snapshots of pending records, optionally narrowed; newest last. */
1908
+ pending(filter = {}) {
1909
+ const records = [];
1910
+ for (const { record } of this.#pending.values()) {
1911
+ if (filter.app !== void 0 && record.app !== filter.app) continue;
1912
+ if (filter.conversationId !== void 0 && record.conversationId !== filter.conversationId) continue;
1913
+ records.push({ ...record });
1914
+ }
1915
+ return records;
1916
+ }
1917
+ /**
1918
+ * Applies a user decision: settles the record, then forwards the decision to
1919
+ * the session that is suspended on it. Returns 'unknown' for an id this host
1920
+ * never saw and 'resolved' for one already decided, so the endpoint can tell
1921
+ * 404 from 409.
1922
+ */
1923
+ respond(approvalId, decision) {
1924
+ const pending = this.#pending.get(approvalId);
1925
+ if (!pending) return this.#settled.has(approvalId) ? "resolved" : "unknown";
1926
+ this.#settle(pending, decision.behavior === "allow" ? "allowed" : "denied", decision.behavior === "allow" ? decision.scope : void 0);
1927
+ pending.respond(decision).catch(() => void 0);
1928
+ return "ok";
1929
+ }
1930
+ /**
1931
+ * Observes a provider-side resolution (session closed, interrupt) arriving
1932
+ * through the event stream. A registry-initiated decision already settled
1933
+ * the record, so this is a no-op for it.
1934
+ */
1935
+ resolved(event) {
1936
+ const pending = this.#pending.get(event.approvalId);
1937
+ if (!pending) return;
1938
+ const status = event.behavior === "allow" ? "allowed" : event.reason === "timeout" ? "timeout" : "denied";
1939
+ this.#settle(pending, status);
1940
+ }
1941
+ /**
1942
+ * Marks an approval whose session's event stream ended without a resolution
1943
+ * (crash, abrupt close) as denied, so nothing stays pending forever.
1944
+ */
1945
+ abandon(approvalId) {
1946
+ const pending = this.#pending.get(approvalId);
1947
+ if (!pending) return;
1948
+ this.#settle(pending, "denied");
1949
+ }
1950
+ /** Reads the app's always-allow grants. */
1951
+ async grants(app) {
1952
+ return (await this.#store.readGrants(app)).tools;
1953
+ }
1954
+ /**
1955
+ * Persists an always-allow grant. Validated against the manifest's askTools:
1956
+ * a grant can promote ask→allow but never allow a tool the manifest did not
1957
+ * declare askable.
1958
+ */
1959
+ async addGrant(app, tool, askTools) {
1960
+ if (!askTools.includes(tool)) throw new AppSdkError(`Tool "${tool}" is not an ask-tier tool of app "${app}"`);
1961
+ const state = await this.#store.readGrants(app);
1962
+ if (state.tools.includes(tool)) return;
1963
+ state.tools.push(tool);
1964
+ await this.#store.writeGrants(app, state);
1965
+ }
1966
+ /** Clears timers and denies everything still pending (host shutdown). */
1967
+ close() {
1968
+ for (const approvalId of [...this.#pending.keys()]) this.abandon(approvalId);
1969
+ }
1970
+ #timeout(approvalId) {
1971
+ const pending = this.#pending.get(approvalId);
1972
+ if (!pending) return;
1973
+ this.#settle(pending, "timeout");
1974
+ pending.respond({
1975
+ behavior: "deny",
1976
+ message: "Approval timed out",
1977
+ reason: "timeout"
1978
+ }).catch(() => void 0);
1979
+ }
1980
+ #settle(pending, status, scope) {
1981
+ clearTimeout(pending.timer);
1982
+ this.#pending.delete(pending.record.approvalId);
1983
+ pending.record.status = status;
1984
+ pending.record.decidedAt = (/* @__PURE__ */ new Date()).toISOString();
1985
+ if (scope !== void 0) pending.record.scope = scope;
1986
+ this.#settled.set(pending.record.approvalId, pending.record);
1987
+ if (this.#settled.size > SETTLED_CAP) {
1988
+ const oldest = this.#settled.keys().next().value;
1989
+ if (oldest !== void 0) this.#settled.delete(oldest);
1990
+ }
1991
+ this.#updateLog(pending.record);
1992
+ }
1993
+ #updateLog(record) {
1994
+ this.#mutateLog(record.app, (approvals) => {
1995
+ const index = approvals.findIndex((entry) => entry.approvalId === record.approvalId);
1996
+ if (index === -1) approvals.push({ ...record });
1997
+ else approvals[index] = { ...record };
1998
+ });
1999
+ }
2000
+ #mutateLog(app, mutate) {
2001
+ const update = this.#logQueue.then(async () => {
2002
+ const log = await this.#store.readApprovalLog(app);
2003
+ mutate(log.approvals);
2004
+ if (log.approvals.length > APPROVAL_LOG_CAP) log.approvals.splice(0, log.approvals.length - APPROVAL_LOG_CAP);
2005
+ await this.#store.writeApprovalLog(app, log);
2006
+ });
2007
+ this.#logQueue = update.catch(() => void 0);
2008
+ }
2009
+ /** Resolves when all queued approvals.json writes have landed (tests). */
2010
+ flush() {
2011
+ return this.#logQueue.then(() => void 0);
2012
+ }
2013
+ };
2014
+ //#endregion
2015
+ //#region src/chat-bus.ts
2016
+ const RING_CAP = 200;
2017
+ /**
2018
+ * Per-conversation event bus: POST /chat enqueues a turn whose events are
2019
+ * published here, and any number of SSE subscribers replay then follow them.
2020
+ * Ids are monotonic per bus; the ring keeps the last {@link RING_CAP} entries
2021
+ * so a reconnecting subscriber can resume from its Last-Event-ID.
2022
+ */
2023
+ var ConversationBus = class {
2024
+ #nextId = 1;
2025
+ #ring = [];
2026
+ #wakers = [];
2027
+ publish(event) {
2028
+ const entry = {
2029
+ id: this.#nextId++,
2030
+ event
2031
+ };
2032
+ this.#ring.push(entry);
2033
+ if (this.#ring.length > RING_CAP) this.#ring.shift();
2034
+ for (const wake of this.#wakers.splice(0)) wake();
2035
+ return entry.id;
2036
+ }
2037
+ /**
2038
+ * Replay then live. With a `lastEventId` still covered by the ring, replay
2039
+ * resumes right after it; one that scrolled out yields a `resync` first.
2040
+ * Without one (a fresh subscriber), replay starts at the current turn — the
2041
+ * entry after the newest `result` — because everything before it is already
2042
+ * served by the history endpoint and would render twice.
2043
+ *
2044
+ * The iterator only ends via the signal (client disconnect); the bus itself
2045
+ * never closes.
2046
+ */
2047
+ async *subscribe(lastEventId, signal) {
2048
+ let cursor;
2049
+ if (lastEventId === void 0) cursor = this.#turnStart();
2050
+ else {
2051
+ const oldest = this.#ring[0]?.id;
2052
+ if (oldest !== void 0 && lastEventId < oldest - 1) {
2053
+ yield {
2054
+ id: this.#nextId - 1,
2055
+ event: { type: "resync" }
2056
+ };
2057
+ cursor = this.#turnStart();
2058
+ } else cursor = lastEventId + 1;
2059
+ }
2060
+ while (!signal?.aborted) {
2061
+ const next = this.#ring.find((entry) => entry.id >= cursor);
2062
+ if (next) {
2063
+ cursor = next.id + 1;
2064
+ yield next;
2065
+ continue;
2066
+ }
2067
+ await this.#nextPublish(signal);
2068
+ }
2069
+ }
2070
+ /** The id right after the newest `result` in the ring — the in-flight turn's start. */
2071
+ #turnStart() {
2072
+ for (let i = this.#ring.length - 1; i >= 0; i--) {
2073
+ const entry = this.#ring[i];
2074
+ if (entry !== void 0 && entry.event.type === "result") return entry.id + 1;
2075
+ }
2076
+ return this.#ring[0]?.id ?? this.#nextId;
2077
+ }
2078
+ #nextPublish(signal) {
2079
+ return new Promise((resolve) => {
2080
+ const onAbort = () => resolve();
2081
+ this.#wakers.push(() => {
2082
+ signal?.removeEventListener("abort", onAbort);
2083
+ resolve();
2084
+ });
2085
+ signal?.addEventListener("abort", onAbort, { once: true });
2086
+ });
2087
+ }
2088
+ };
1499
2089
  //#endregion
1500
2090
  //#region src/scheduler.ts
1501
2091
  const RUN_LOG_CAP = 50;
@@ -1955,6 +2545,45 @@ function roCard(title, children) {
1955
2545
  ]);
1956
2546
  }
1957
2547
 
2548
+ // Every tool call passes the host's tool gateway: user overrides first
2549
+ // (never past a manifest deny), then the manifest's rules, then the default.
2550
+ function ruleRow(r) {
2551
+ return el('div', { class: 'ro-item' }, [
2552
+ el('span', { class: 'chip ' + (r.decision === 'allow' ? 'on' : r.decision === 'deny' ? 'off' : ''),
2553
+ text: r.decision }),
2554
+ document.createTextNode(' '),
2555
+ el('code', { text: r.summary }),
2556
+ el('div', { class: 'desc', text: (r.description && r.description !== r.summary ? r.description + ' · ' : '') + 'id: ' + r.id }),
2557
+ ]);
2558
+ }
2559
+
2560
+ function policyCard(p) {
2561
+ const body = [];
2562
+ if (!p.gated) {
2563
+ body.push(el('div', { class: 'desc', text: 'This AI engine cannot gate individual tool calls; only name-level allow/ask lists apply.' }));
2564
+ }
2565
+ if (p.user.length > 0) {
2566
+ body.push(el('h3', { text: 'Your overrides' }), ...p.user.map(ruleRow));
2567
+ }
2568
+ body.push(el('h3', { text: 'Declared by the app' }));
2569
+ body.push(...(p.manifest.length > 0 ? p.manifest.map(ruleRow) : [el('div', { class: 'desc', text: 'No rules.' })]));
2570
+ body.push(el('div', { class: 'desc', text: 'Anything else: ' + p.default + '.' }));
2571
+ return roCard('Tool policy', body);
2572
+ }
2573
+
2574
+ function toolCallsCard(calls) {
2575
+ return roCard('Recent tool calls', [usageTable(
2576
+ ['When', 'Tool', 'Decision', 'Why', 'Input'],
2577
+ calls.slice().reverse().map((c) => [
2578
+ new Date(c.at).toLocaleString(),
2579
+ c.tool,
2580
+ c.decision + ' (' + c.source + ')',
2581
+ c.reason,
2582
+ c.inputPreview.length > 80 ? c.inputPreview.slice(0, 80) + '…' : c.inputPreview,
2583
+ ]),
2584
+ )]);
2585
+ }
2586
+
1958
2587
  function renderManifest(m) {
1959
2588
  const root = document.getElementById('manifest-cards');
1960
2589
  root.replaceChildren();
@@ -2000,10 +2629,8 @@ function renderManifest(m) {
2000
2629
  ]))));
2001
2630
  }
2002
2631
 
2003
- if (m.allowedTools.length > 0) {
2004
- cards.push(roCard('Allowed tools', m.allowedTools.map((t) =>
2005
- el('div', {}, [el('code', { text: t })]))));
2006
- }
2632
+ if (m.policy) cards.push(policyCard(m.policy));
2633
+ if (m.toolCalls && m.toolCalls.length > 0) cards.push(toolCallsCard(m.toolCalls));
2007
2634
 
2008
2635
  if (m.jobs.length > 0) {
2009
2636
  cards.push(roCard('Jobs', m.jobs.map((j) => el('div', { class: 'ro-item' }, [
@@ -2037,7 +2664,16 @@ async function load() {
2037
2664
  return;
2038
2665
  }
2039
2666
  render(await res.json());
2040
- if (manRes.ok) renderManifest(await manRes.json());
2667
+ if (manRes.ok) {
2668
+ const m = await manRes.json();
2669
+ const [policyRes, callsRes] = await Promise.all([
2670
+ fetch('/api/apps/' + APP + '/policy'),
2671
+ fetch('/api/apps/' + APP + '/tool-calls?limit=20'),
2672
+ ]);
2673
+ if (policyRes.ok) m.policy = await policyRes.json();
2674
+ if (callsRes.ok) m.toolCalls = (await callsRes.json()).calls;
2675
+ renderManifest(m);
2676
+ }
2041
2677
  }
2042
2678
 
2043
2679
  document.getElementById('save').addEventListener('click', async () => {
@@ -2065,6 +2701,193 @@ load();
2065
2701
  `;
2066
2702
  }
2067
2703
  //#endregion
2704
+ //#region src/tool-gateway.ts
2705
+ const INPUT_PREVIEW_CHARS = 1e3;
2706
+ /** Calls the gate answered `ask` and nobody has decided yet, bounded per host. */
2707
+ const PENDING_ASK_CAP = 500;
2708
+ /** The audit ledger is trimmed to this many newest lines... */
2709
+ const AUDIT_KEEP = 5e3;
2710
+ /** ...checked once every this many appends per app. */
2711
+ const AUDIT_TRIM_EVERY = 500;
2712
+ /**
2713
+ * The host's tool gateway: every tool call any app session makes — chat,
2714
+ * cron, app routes; built-in, MCP, subagent — is decided here (see
2715
+ * `SessionOptions.toolGate`). Layers, in order:
2716
+ *
2717
+ * 1. the manifest policy (rules, then `allowedTools`/`askTools` sugar);
2718
+ * 2. the user's overrides (`policy.json`, plus legacy `grants.json`) — which
2719
+ * may tighten anything but never loosen past a manifest `deny`;
2720
+ * 3. session grants ("approve for session"), keyed by the rule that asked;
2721
+ *
2722
+ * and every decision is appended to the app's `tool-calls.jsonl` audit
2723
+ * ledger. `ask` verdicts go on to the provider's interactive approval flow;
2724
+ * `applyDecision` turns the user's answer into a session grant or a
2725
+ * persistent user rule.
2726
+ */
2727
+ var ToolGateway = class {
2728
+ #store;
2729
+ #sessionGrants = /* @__PURE__ */ new Map();
2730
+ #pendingAsks = /* @__PURE__ */ new Map();
2731
+ #appendCounts = /* @__PURE__ */ new Map();
2732
+ /** Serializes ledger appends/trims and policy.json writes. */
2733
+ #queue = Promise.resolve();
2734
+ constructor(store) {
2735
+ this.#store = store;
2736
+ }
2737
+ /** Builds the gate for one new session of `app`. */
2738
+ session(app, manifestPolicy) {
2739
+ const sessionKey = randomUUID();
2740
+ const gate = async (call) => this.#decide(app, sessionKey, manifestPolicy, call);
2741
+ return {
2742
+ gate,
2743
+ end: () => this.#sessionGrants.delete(sessionKey)
2744
+ };
2745
+ }
2746
+ /** The user override layer as stored: policy.json rules, then legacy grants. */
2747
+ async userPolicy(app) {
2748
+ const [policy, grants] = await Promise.all([this.#store.readUserPolicy(app), this.#store.readGrants(app)]);
2749
+ return { rules: [...policy.rules, ...grants.tools.map((tool) => ({
2750
+ id: `grants.${tool}`,
2751
+ tool,
2752
+ decision: "allow"
2753
+ }))] };
2754
+ }
2755
+ /**
2756
+ * Applies the scope of a user's answer to a call this gateway sent to
2757
+ * `ask`: `session` grants the asking rule for the rest of the session,
2758
+ * `always` appends an allow rule with the asking rule's scope to the app's
2759
+ * policy.json. Returns false for approvals this gateway never saw (legacy
2760
+ * name-level sessions), so the caller can fall back.
2761
+ */
2762
+ async applyDecision(approvalId, decision) {
2763
+ const pending = this.#pendingAsks.get(approvalId);
2764
+ if (!pending) return false;
2765
+ this.#pendingAsks.delete(approvalId);
2766
+ if (decision.behavior !== "allow") return true;
2767
+ if (decision.scope === "session" || decision.scope === "always") {
2768
+ let grants = this.#sessionGrants.get(pending.sessionKey);
2769
+ if (!grants) {
2770
+ grants = /* @__PURE__ */ new Set();
2771
+ this.#sessionGrants.set(pending.sessionKey, grants);
2772
+ }
2773
+ grants.add(pending.grantKey);
2774
+ }
2775
+ if (decision.scope === "always") await this.#persistAlways(pending);
2776
+ return true;
2777
+ }
2778
+ /** The newest audit lines for an app, newest last. */
2779
+ async recentCalls(app, limit = 100) {
2780
+ await this.flush();
2781
+ return (await this.#store.readToolCalls(app)).slice(-limit);
2782
+ }
2783
+ /** Resolves when queued ledger and policy writes have landed (tests). */
2784
+ flush() {
2785
+ return this.#queue.then(() => void 0);
2786
+ }
2787
+ async #decide(app, sessionKey, manifestPolicy, call) {
2788
+ const manifest = evaluatePolicy(manifestPolicy, call);
2789
+ let verdict = manifest;
2790
+ let source = manifest.ruleId !== void 0 ? "manifest" : "default";
2791
+ if (manifest.decision !== "deny") {
2792
+ const user = evaluatePolicy(compilePolicy({
2793
+ default: "deny",
2794
+ ...await this.userPolicy(app)
2795
+ }), call);
2796
+ if (user.ruleId !== void 0) {
2797
+ verdict = user;
2798
+ source = "user";
2799
+ }
2800
+ }
2801
+ let result;
2802
+ if (verdict.decision === "ask") {
2803
+ const grantKey = verdict.ruleId ?? `default:${call.tool}`;
2804
+ if (this.#sessionGrants.get(sessionKey)?.has(grantKey)) {
2805
+ source = "session";
2806
+ result = { decision: "allow" };
2807
+ } else {
2808
+ this.#trackAsk(call.toolUseId, {
2809
+ app,
2810
+ sessionKey,
2811
+ grantKey,
2812
+ tool: call.tool,
2813
+ ...verdict.rule !== void 0 && { rule: verdict.rule }
2814
+ });
2815
+ result = {
2816
+ decision: "ask",
2817
+ ...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
2818
+ reason: verdict.reason
2819
+ };
2820
+ }
2821
+ } else if (verdict.decision === "allow") result = { decision: "allow" };
2822
+ else result = {
2823
+ decision: "deny",
2824
+ reason: `Denied by policy: ${verdict.reason}`
2825
+ };
2826
+ this.#audit(app, {
2827
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2828
+ tool: call.tool,
2829
+ toolUseId: call.toolUseId,
2830
+ inputPreview: preview(call.input),
2831
+ decision: result.decision,
2832
+ source,
2833
+ ...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
2834
+ reason: source === "session" ? `session grant: ${verdict.reason}` : verdict.reason
2835
+ });
2836
+ return result;
2837
+ }
2838
+ #trackAsk(toolUseId, pending) {
2839
+ this.#pendingAsks.set(toolUseId, pending);
2840
+ if (this.#pendingAsks.size > PENDING_ASK_CAP) {
2841
+ const oldest = this.#pendingAsks.keys().next().value;
2842
+ if (oldest !== void 0) this.#pendingAsks.delete(oldest);
2843
+ }
2844
+ }
2845
+ #persistAlways(pending) {
2846
+ const scope = pending.rule ?? {
2847
+ tool: pending.tool,
2848
+ decision: "ask"
2849
+ };
2850
+ const rule = {
2851
+ id: `always.${pending.grantKey}`,
2852
+ tool: scope.tool,
2853
+ ...scope.input !== void 0 && { input: scope.input },
2854
+ decision: "allow",
2855
+ description: `Always allowed: ${describeRule(scope)}`
2856
+ };
2857
+ const write = this.#queue.then(async () => {
2858
+ const policy = await this.#store.readUserPolicy(pending.app);
2859
+ if (policy.rules.some((existing) => existing.id === rule.id)) return;
2860
+ policy.rules.unshift(rule);
2861
+ await this.#store.writeUserPolicy(pending.app, policy);
2862
+ });
2863
+ this.#queue = write.catch(() => void 0);
2864
+ return write;
2865
+ }
2866
+ #audit(app, record) {
2867
+ const write = this.#queue.then(async () => {
2868
+ await this.#store.appendToolCall(app, record);
2869
+ const count = (this.#appendCounts.get(app) ?? 0) + 1;
2870
+ this.#appendCounts.set(app, count);
2871
+ if (count % AUDIT_TRIM_EVERY === 0) {
2872
+ const records = await this.#store.readToolCalls(app);
2873
+ if (records.length > AUDIT_KEEP) await this.#store.writeToolCalls(app, records.slice(-5e3));
2874
+ }
2875
+ });
2876
+ this.#queue = write.catch((err) => {
2877
+ console.warn(`alashi: failed to write tool-call audit for "${app}": ${String(err)}`);
2878
+ });
2879
+ }
2880
+ };
2881
+ function preview(input) {
2882
+ let json;
2883
+ try {
2884
+ json = JSON.stringify(input) ?? String(input);
2885
+ } catch {
2886
+ json = String(input);
2887
+ }
2888
+ return json.length > INPUT_PREVIEW_CHARS ? `${json.slice(0, INPUT_PREVIEW_CHARS)}…` : json;
2889
+ }
2890
+ //#endregion
2068
2891
  //#region src/server.ts
2069
2892
  const CONTENT_TYPES = {
2070
2893
  ".html": "text/html; charset=utf-8",
@@ -2136,21 +2959,42 @@ function appErrorPage(name, err) {
2136
2959
  * GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
2137
2960
  * per-model token totals, monthly rollups,
2138
2961
  * `recorded`, and the budget status
2962
+ * GET /api/apps/:name/turns the per-turn ledger that summary folds: `{ turns }`,
2963
+ * oldest first, each with `at`, `costUsd`, and the
2964
+ * session/model attribution the buckets drop.
2965
+ * ?since=<timestamp> (inclusive) and ?session=<id>
2966
+ * narrow it, ANDed; an unreadable `since` is a 400
2139
2967
  * GET /api/apps/:name/budget today's spend against the app's daily cap,
2140
2968
  * plus `recorded`
2141
2969
  * PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
2142
2970
  * (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)
2971
+ * POST /api/apps/:name/chat { message, conversationId? } -> 202 { conversationId };
2972
+ * the turn runs detached and its AgentEvents are published
2973
+ * on the conversation's event bus (omit conversationId to
2974
+ * start a new conversation; the id is also echoed in the
2975
+ * x-conversation-id response header; 402 when the app is
2976
+ * over its daily spend cap)
2977
+ * GET /api/apps/:name/chat/:conversationId/events standing SSE from the conversation
2978
+ * bus: replay from Last-Event-ID (or ?lastEventId=), then
2979
+ * live; a scrolled-out id gets a `resync` event first
2980
+ * GET /api/apps/:name/chat/:conversationId/pending-approvals reconnect snapshot of
2981
+ * undecided tool approvals for this conversation
2982
+ * POST /api/apps/:name/chat/:conversationId/approvals/:approvalId
2983
+ * { behavior: "allow"|"deny", scope?, message? } -> decide a
2984
+ * pending approval (scope "always" persists a grant);
2985
+ * 404 unknown/expired, 409 already decided
2986
+ * GET /api/approvals host-wide pending tool approvals (chat and cron alike)
2987
+ * POST /api/approvals/:approvalId decide a pending approval regardless of source
2147
2988
  * GET /api/apps/:name/chat/conversations conversations, most recent first
2148
2989
  * POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
2149
2990
  * optionally adopting an existing provider session (e.g. an
2150
2991
  * app-run investigation) so chat continues where it left off
2151
2992
  * GET /api/apps/:name/chat/:conversationId/history past turns from the provider transcript
2993
+ * plus the conversation's settled approval records
2152
2994
  * GET /api/apps/:name/jobs manifest-declared jobs with schedule/status
2153
2995
  * POST /api/apps/:name/jobs/:job/run trigger a job now (409 while already running)
2996
+ * GET /api/apps/:name/policy effective tool policy: user overrides + manifest rules
2997
+ * GET /api/apps/:name/tool-calls?limit= newest gated tool calls (audit ledger)
2154
2998
  * GET /api/apps/:name/manifest read-only app definition: instructions, agents,
2155
2999
  * skills (with dir contents), allowed tools,
2156
3000
  * MCP servers (with configured state), jobs, model
@@ -2168,9 +3012,19 @@ function createServer(store, provider, options = {}) {
2168
3012
  const apps = /* @__PURE__ */ new Map();
2169
3013
  const sessions = /* @__PURE__ */ new Map();
2170
3014
  const usage = new UsageRecorder(store);
3015
+ const approvals = new ApprovalRegistry(store);
3016
+ const gateway = new ToolGateway(store);
3017
+ approvals.init().catch((err) => {
3018
+ console.warn(`alashi: failed to read approval timeout config: ${String(err)}`);
3019
+ });
3020
+ store.listInstalled().then((records) => Promise.all(records.map((record) => usage.compact(record.name).catch((err) => {
3021
+ console.warn(`alashi: failed to compact turns.jsonl for "${record.name}": ${String(err)}`);
3022
+ })))).catch((err) => {
3023
+ console.warn(`alashi: failed to compact turn ledgers: ${String(err)}`);
3024
+ });
2171
3025
  async function startApp(name) {
2172
3026
  const { definition, manifest } = await loadApp(store.appRepoDir(name));
2173
- const ctx = await buildAppContext(store, manifest, provider, usage);
3027
+ const ctx = await buildAppContext(store, manifest, provider, usage, approvals, gateway);
2174
3028
  return {
2175
3029
  manifest,
2176
3030
  ctx,
@@ -2215,6 +3069,28 @@ function createServer(store, provider, options = {}) {
2215
3069
  }
2216
3070
  return session;
2217
3071
  }
3072
+ /** One bus per conversation; created on first use, kept for reconnects. */
3073
+ const buses = /* @__PURE__ */ new Map();
3074
+ function getBus(name, conversationId) {
3075
+ const key = `${name}/${conversationId}`;
3076
+ let bus = buses.get(key);
3077
+ if (!bus) {
3078
+ bus = new ConversationBus();
3079
+ buses.set(key, bus);
3080
+ }
3081
+ return bus;
3082
+ }
3083
+ /**
3084
+ * Per-conversation turn FIFO: POST /chat enqueues and returns 202, the
3085
+ * detached runner drains one send() at a time (providers reject concurrent
3086
+ * turns on one session). Runners publish their own failures to the bus, so
3087
+ * a queued promise never rejects into the void.
3088
+ */
3089
+ const turnQueues = /* @__PURE__ */ new Map();
3090
+ function enqueueTurn(key, run) {
3091
+ const next = (turnQueues.get(key) ?? Promise.resolve()).then(run);
3092
+ turnQueues.set(key, next.catch(() => void 0));
3093
+ }
2218
3094
  const scheduler = new Scheduler(store, getApp);
2219
3095
  /** Rebuilds the request with `prefix` stripped and hands it to the app's handler. */
2220
3096
  function dispatch(handler, raw, prefix) {
@@ -2329,6 +3205,22 @@ ${rows}
2329
3205
  </tbody>
2330
3206
  <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
3207
  </table>`;
3208
+ const pendingApprovals = approvals.pending();
3209
+ const approvalsSection = pendingApprovals.length === 0 ? "" : `<h2>Pending tool approvals</h2>
3210
+ <div class="panel">
3211
+ <table>
3212
+ <thead><tr><th>App</th><th>Tool</th><th>Requested</th><th>Expires</th><th>Where</th></tr></thead>
3213
+ <tbody>
3214
+ ${pendingApprovals.map((approval) => `<tr>
3215
+ <td><a href="/app/${escapeHtml(approval.app)}/">${escapeHtml(approval.app)}</a></td>
3216
+ <td><code>${escapeHtml(approval.tool)}</code></td>
3217
+ <td class="muted">${escapeHtml(approval.requestedAt)}</td>
3218
+ <td class="muted">${escapeHtml(approval.expiresAt)}</td>
3219
+ <td class="muted">${approval.conversationId ? "chat" : "background"}</td>
3220
+ </tr>`).join("\n")}
3221
+ </tbody>
3222
+ </table>
3223
+ </div>`;
2332
3224
  return c.html(`<!doctype html>
2333
3225
  <html lang="en">
2334
3226
  <head>
@@ -2345,6 +3237,7 @@ ${rows}
2345
3237
  <div class="panel">
2346
3238
  ${body}
2347
3239
  </div>
3240
+ ${approvalsSection}
2348
3241
  </main>
2349
3242
  </body>
2350
3243
  </html>
@@ -2390,6 +3283,33 @@ ${body}
2390
3283
  budget: budgetStatusFor(summary, appBudget(config, name))
2391
3284
  });
2392
3285
  });
3286
+ /**
3287
+ * The per-turn ledger the usage summary is a fold over: one entry per metered
3288
+ * turn, oldest first, carrying the session attribution the day/month buckets
3289
+ * throw away. `?since=` and `?session=` are optional and ANDed.
3290
+ *
3291
+ * `since` is normalised to a canonical ISO instant before it reaches the
3292
+ * recorder, whose bound is compared lexically against the stored `at`. That
3293
+ * keeps a bare `2026-03-02` working as "from that day on" while making an
3294
+ * offset-bearing timestamp (`...T12:00+05:30`) mean the instant it names
3295
+ * rather than whatever its digits happen to sort next to.
3296
+ */
3297
+ app.get("/api/apps/:name/turns", async (c) => {
3298
+ const name = c.req.param("name");
3299
+ if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3300
+ const rawSince = c.req.query("since");
3301
+ let since;
3302
+ if (rawSince !== void 0) {
3303
+ const parsed = Date.parse(rawSince);
3304
+ if (Number.isNaN(parsed)) return c.json({ error: `"since" must be a timestamp, got "${rawSince}"` }, 400);
3305
+ since = new Date(parsed).toISOString();
3306
+ }
3307
+ const sessionId = c.req.query("session");
3308
+ return c.json({ turns: await usage.turns(name, {
3309
+ ...since !== void 0 && { since },
3310
+ ...sessionId !== void 0 && { sessionId }
3311
+ }) });
3312
+ });
2393
3313
  app.get("/api/apps/:name/budget", async (c) => {
2394
3314
  const name = c.req.param("name");
2395
3315
  if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -2458,7 +3378,12 @@ ${body}
2458
3378
  const convo = conversations.find((conv) => conv.id === conversationId);
2459
3379
  if (!convo) return c.json({ error: "Unknown conversation" }, 404);
2460
3380
  const messages = convo.sessionId ? await provider.history?.(convo.sessionId) ?? [] : [];
2461
- return c.json({ messages });
3381
+ await approvals.flush();
3382
+ const settled = (await store.readApprovalLog(name)).approvals.filter((entry) => entry.conversationId === conversationId && entry.status !== "pending");
3383
+ return c.json({
3384
+ messages,
3385
+ approvals: settled
3386
+ });
2462
3387
  });
2463
3388
  /** Drops the cached app and its warm sessions so the next request rebuilds with fresh config. */
2464
3389
  function restartApp(name) {
@@ -2469,6 +3394,33 @@ ${body}
2469
3394
  session.then((s) => s.close()).catch(() => void 0);
2470
3395
  }
2471
3396
  }
3397
+ app.get("/api/apps/:name/policy", async (c) => {
3398
+ const name = c.req.param("name");
3399
+ const running = await getApp(name);
3400
+ if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
3401
+ const manifestPolicy = permissionsPolicy(running.manifest.permissions);
3402
+ const user = await gateway.userPolicy(name);
3403
+ const describe = (rule) => ({
3404
+ ...rule,
3405
+ summary: describeRule(rule)
3406
+ });
3407
+ return c.json({
3408
+ gated: provider.supportsToolGate === true && provider.supportsApprovals === true,
3409
+ default: manifestPolicy.default,
3410
+ user: user.rules.map(describe),
3411
+ manifest: manifestPolicy.rules.map((rule, index) => describe({
3412
+ ...rule,
3413
+ id: rule.id ?? `rules.${index}`
3414
+ }))
3415
+ });
3416
+ });
3417
+ app.get("/api/apps/:name/tool-calls", async (c) => {
3418
+ const name = c.req.param("name");
3419
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3420
+ const raw = Number(c.req.query("limit") ?? 100);
3421
+ const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1e3) : 100;
3422
+ return c.json({ calls: await gateway.recentCalls(name, limit) });
3423
+ });
2472
3424
  app.get("/api/apps/:name/manifest", async (c) => {
2473
3425
  const name = c.req.param("name");
2474
3426
  const running = await getApp(name);
@@ -2611,8 +3563,12 @@ ${body}
2611
3563
  } catch (err) {
2612
3564
  return c.json({ error: `Failed to start AI session: ${String(err)}` }, 502);
2613
3565
  }
2614
- c.header("x-conversation-id", conversation.id);
2615
- return streamSSE(c, async (stream) => {
3566
+ const bus = getBus(name, conversation.id);
3567
+ bus.publish({
3568
+ type: "user_message",
3569
+ text: message
3570
+ });
3571
+ enqueueTurn(`${name}/${conversation.id}`, async () => {
2616
3572
  try {
2617
3573
  for await (const event of session.send(message)) {
2618
3574
  if (event.type === "session") {
@@ -2620,23 +3576,111 @@ ${body}
2620
3576
  conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2621
3577
  await store.writeChatState(name, state);
2622
3578
  }
2623
- await stream.writeSSE({
2624
- event: event.type,
2625
- data: JSON.stringify(event)
2626
- });
3579
+ if (event.type === "approval_required") approvals.annotate(event.approvalId, { conversationId: conversation.id });
3580
+ bus.publish(event);
2627
3581
  }
2628
3582
  } catch (err) {
2629
- await stream.writeSSE({
2630
- event: "result",
2631
- data: JSON.stringify({
2632
- type: "result",
2633
- ok: false,
2634
- error: String(err)
2635
- })
3583
+ bus.publish({
3584
+ type: "result",
3585
+ ok: false,
3586
+ error: String(err)
2636
3587
  });
2637
3588
  }
2638
3589
  });
3590
+ c.header("x-conversation-id", conversation.id);
3591
+ return c.json({ conversationId: conversation.id }, 202);
2639
3592
  });
3593
+ app.get("/api/apps/:name/chat/:conversationId/events", async (c) => {
3594
+ const name = c.req.param("name");
3595
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3596
+ const conversationId = c.req.param("conversationId");
3597
+ const { conversations } = await getChatState(name);
3598
+ if (!conversations.some((conv) => conv.id === conversationId)) return c.json({ error: "Unknown conversation" }, 404);
3599
+ const raw = c.req.header("last-event-id") ?? c.req.query("lastEventId");
3600
+ const lastEventId = raw !== void 0 && /^\d+$/.test(raw) ? Number(raw) : void 0;
3601
+ const bus = getBus(name, conversationId);
3602
+ const signal = c.req.raw.signal;
3603
+ return streamSSE(c, async (stream) => {
3604
+ for await (const entry of bus.subscribe(lastEventId, signal)) await stream.writeSSE({
3605
+ id: String(entry.id),
3606
+ data: JSON.stringify(entry.event)
3607
+ });
3608
+ });
3609
+ });
3610
+ app.get("/api/apps/:name/chat/:conversationId/pending-approvals", async (c) => {
3611
+ const name = c.req.param("name");
3612
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3613
+ return c.json({ approvals: approvals.pending({
3614
+ app: name,
3615
+ conversationId: c.req.param("conversationId")
3616
+ }) });
3617
+ });
3618
+ /** Parses an approval decision body; returns undefined (with a 400 sent) when invalid. */
3619
+ function parseDecision(body) {
3620
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
3621
+ const { behavior, scope, message } = body;
3622
+ if (behavior === "allow") {
3623
+ if (scope !== void 0 && scope !== "once" && scope !== "session" && scope !== "always") return;
3624
+ return {
3625
+ behavior: "allow",
3626
+ ...scope !== void 0 && { scope }
3627
+ };
3628
+ }
3629
+ if (behavior === "deny") {
3630
+ if (message !== void 0 && typeof message !== "string") return void 0;
3631
+ return {
3632
+ behavior: "deny",
3633
+ ...message !== void 0 && { message },
3634
+ reason: "user"
3635
+ };
3636
+ }
3637
+ }
3638
+ /**
3639
+ * Decides one pending approval. `expect` narrows which records this route
3640
+ * may touch (the conversation-scoped route cannot decide another app's or
3641
+ * conversation's approvals).
3642
+ */
3643
+ async function decideApproval(c, approvalId, expect) {
3644
+ let body;
3645
+ try {
3646
+ body = await c.req.json();
3647
+ } catch {
3648
+ return c.json({ error: "Body must be JSON" }, 400);
3649
+ }
3650
+ const decision = parseDecision(body);
3651
+ if (!decision) return c.json({ error: "Body must be { \"behavior\": \"allow\"|\"deny\", \"scope\"?, \"message\"? }" }, 400);
3652
+ const record = approvals.get(approvalId);
3653
+ 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);
3654
+ let gatewayHandled;
3655
+ try {
3656
+ gatewayHandled = record.status === "pending" && await gateway.applyDecision(approvalId, decision);
3657
+ } catch (err) {
3658
+ return c.json({ error: `Failed to persist grant: ${String(err)}` }, 500);
3659
+ }
3660
+ if (!gatewayHandled && decision.behavior === "allow" && decision.scope === "always") {
3661
+ const running = await getApp(record.app);
3662
+ if (running) try {
3663
+ await approvals.addGrant(record.app, record.tool, running.manifest.permissions.askTools);
3664
+ } catch (err) {
3665
+ return c.json({ error: String(err) }, 400);
3666
+ }
3667
+ }
3668
+ const outcome = approvals.respond(approvalId, decision);
3669
+ if (outcome === "unknown") return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
3670
+ if (outcome === "resolved") return c.json({ error: `Approval "${approvalId}" is already decided` }, 409);
3671
+ return c.json({ approval: approvals.get(approvalId) });
3672
+ }
3673
+ app.post("/api/apps/:name/chat/:conversationId/approvals/:approvalId", async (c) => {
3674
+ const name = c.req.param("name");
3675
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3676
+ return decideApproval(c, c.req.param("approvalId"), {
3677
+ app: name,
3678
+ conversationId: c.req.param("conversationId")
3679
+ });
3680
+ });
3681
+ /** Host-wide views: cron/app-route approvals have no conversation to scope by. */
3682
+ app.get("/api/approvals", (c) => c.json({ approvals: approvals.pending() }));
3683
+ app.post("/api/approvals/:approvalId", (c) => decideApproval(c, c.req.param("approvalId")));
2640
3684
  app.all("/app/:name/api/*", async (c) => {
2641
3685
  const name = c.req.param("name");
2642
3686
  const running = await getApp(name);
@@ -2668,6 +3712,7 @@ ${body}
2668
3712
  auth,
2669
3713
  async close() {
2670
3714
  scheduler.stop();
3715
+ approvals.close();
2671
3716
  const open = [...sessions.values()];
2672
3717
  sessions.clear();
2673
3718
  await Promise.all(open.map(async (p) => {
@@ -2739,9 +3784,24 @@ var Store = class {
2739
3784
  appJobStatePath(name) {
2740
3785
  return join(this.appDir(name), "jobs.json");
2741
3786
  }
3787
+ appApprovalLogPath(name) {
3788
+ return join(this.appDir(name), "approvals.json");
3789
+ }
3790
+ appGrantsPath(name) {
3791
+ return join(this.appDir(name), "grants.json");
3792
+ }
3793
+ appPolicyPath(name) {
3794
+ return join(this.appDir(name), "policy.json");
3795
+ }
3796
+ appToolCallsPath(name) {
3797
+ return join(this.appDir(name), "tool-calls.jsonl");
3798
+ }
2742
3799
  appUsagePath(name) {
2743
3800
  return join(this.appDir(name), "usage.json");
2744
3801
  }
3802
+ appTurnsPath(name) {
3803
+ return join(this.appDir(name), "turns.jsonl");
3804
+ }
2745
3805
  registryCachePath(name) {
2746
3806
  if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
2747
3807
  return join(this.registriesDir, `${name}.json`);
@@ -2770,6 +3830,61 @@ var Store = class {
2770
3830
  async writeChatState(name, state) {
2771
3831
  await writeJson(this.appChatStatePath(name), state);
2772
3832
  }
3833
+ async readApprovalLog(name) {
3834
+ return { approvals: (await readJson(this.appApprovalLogPath(name)))?.approvals ?? [] };
3835
+ }
3836
+ async writeApprovalLog(name, log) {
3837
+ await writeJson(this.appApprovalLogPath(name), log);
3838
+ }
3839
+ async readGrants(name) {
3840
+ return { tools: (await readJson(this.appGrantsPath(name)))?.tools ?? [] };
3841
+ }
3842
+ async writeGrants(name, state) {
3843
+ await writeJson(this.appGrantsPath(name), state);
3844
+ }
3845
+ async readUserPolicy(name) {
3846
+ const state = await readJson(this.appPolicyPath(name));
3847
+ return { rules: Array.isArray(state?.rules) ? state.rules : [] };
3848
+ }
3849
+ async writeUserPolicy(name, policy) {
3850
+ await writeJson(this.appPolicyPath(name), policy);
3851
+ }
3852
+ /** Appends one line to the app's tool-call audit ledger. */
3853
+ async appendToolCall(name, record) {
3854
+ const path = this.appToolCallsPath(name);
3855
+ await mkdir(join(path, ".."), { recursive: true });
3856
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
3857
+ }
3858
+ /**
3859
+ * Parseable lines of the tool-call ledger, oldest first. Like the turn
3860
+ * ledger, a truncated or hand-mangled line costs only itself.
3861
+ */
3862
+ async readToolCalls(name) {
3863
+ let content;
3864
+ try {
3865
+ content = await readFile(this.appToolCallsPath(name), "utf8");
3866
+ } catch (err) {
3867
+ if (err.code === "ENOENT") return [];
3868
+ throw err;
3869
+ }
3870
+ const records = [];
3871
+ for (const line of content.split("\n")) {
3872
+ if (line.trim() === "") continue;
3873
+ try {
3874
+ const record = JSON.parse(line);
3875
+ if (typeof record?.at === "string" && typeof record.tool === "string") records.push(record);
3876
+ } catch {}
3877
+ }
3878
+ return records;
3879
+ }
3880
+ /** Replaces the tool-call ledger atomically (tmp file + rename), for trimming. */
3881
+ async writeToolCalls(name, records) {
3882
+ const path = this.appToolCallsPath(name);
3883
+ await mkdir(join(path, ".."), { recursive: true });
3884
+ const tmp = `${path}.tmp`;
3885
+ await writeFile(tmp, records.map((r) => `${JSON.stringify(r)}\n`).join(""), "utf8");
3886
+ await rename(tmp, path);
3887
+ }
2773
3888
  async readJobState(name) {
2774
3889
  const state = await readJson(this.appJobStatePath(name));
2775
3890
  return {
@@ -2790,6 +3905,51 @@ var Store = class {
2790
3905
  async writeUsageState(name, state) {
2791
3906
  await writeJson(this.appUsagePath(name), state);
2792
3907
  }
3908
+ /** Appends one line to the app's turn ledger, creating the container if needed. */
3909
+ async appendTurnRecord(name, record) {
3910
+ const path = this.appTurnsPath(name);
3911
+ await mkdir(join(path, ".."), { recursive: true });
3912
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
3913
+ }
3914
+ /**
3915
+ * Every parseable line of the app's turn ledger, in file order (oldest
3916
+ * first); an absent file reads as no turns.
3917
+ *
3918
+ * An append-only log written across crashes and hand edits will eventually
3919
+ * contain a truncated or malformed line. One bad line must cost only itself,
3920
+ * so unparseable lines — and lines without an `at` timestamp, which nothing
3921
+ * downstream could place in time — are skipped rather than thrown on.
3922
+ */
3923
+ async readTurnRecords(name) {
3924
+ let content;
3925
+ try {
3926
+ content = await readFile(this.appTurnsPath(name), "utf8");
3927
+ } catch (err) {
3928
+ if (err.code === "ENOENT") return [];
3929
+ throw err;
3930
+ }
3931
+ const records = [];
3932
+ for (const line of content.split("\n")) {
3933
+ if (line.trim() === "") continue;
3934
+ let parsed;
3935
+ try {
3936
+ parsed = JSON.parse(line);
3937
+ } catch {
3938
+ continue;
3939
+ }
3940
+ const record = parsed;
3941
+ if (typeof record?.at === "string") records.push(record);
3942
+ }
3943
+ return records;
3944
+ }
3945
+ /** Replaces the app's turn ledger atomically (tmp file + rename), for compaction. */
3946
+ async writeTurnRecords(name, records) {
3947
+ const path = this.appTurnsPath(name);
3948
+ await mkdir(join(path, ".."), { recursive: true });
3949
+ const tmp = `${path}.tmp`;
3950
+ await writeFile(tmp, records.map((record) => `${JSON.stringify(record)}\n`).join(""), "utf8");
3951
+ await rename(tmp, path);
3952
+ }
2793
3953
  get authStatePath() {
2794
3954
  return join(this.root, "auth.json");
2795
3955
  }
@@ -2823,4 +3983,4 @@ var Store = class {
2823
3983
  //#endregion
2824
3984
  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
3985
 
2826
- //# sourceMappingURL=store-DoAGOynU.mjs.map
3986
+ //# sourceMappingURL=store-DvLX0Kft.mjs.map