autopilot-ai 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4899,8 +4899,8 @@ var init_subagent_policy = __esm({
4899
4899
  "src/intent/subagent-policy.ts"() {
4900
4900
  "use strict";
4901
4901
  init_jev();
4902
- EXPLICIT_NO = /\b(?:do\s+not|don't|dont|never|avoid|without|no)\s+(?:(?:(?:want\s+to\s+)?(?:use|using|spawn|call|ask|start|launch|skip))\s+)?(?:(?:any|the|a)\s+)?(?:sub[- ]?agents?|agents?|workers?)\b|\b(?:do\s+not|don't|dont|never|avoid|without|no)\s+delegat(?:e|ion|ing)\b|\bwork\s+sequentially\b/i;
4903
- EXPLICIT_YES = /\b(?:use|spawn|call|start|launch|ask|send|delegate\s+to)\s+(?:(?:some|several|multiple|parallel)\s+)?(?:sub[- ]?agents?|agents?|workers?)\b|\b(?:parallelize|split)\b.{0,50}\b(?:agents?|workers?|subtasks?)\b/i;
4902
+ EXPLICIT_NO = /\b(?:do\s+not|don't|dont|never|avoid|without|no)\s+(?:(?:(?:want\s+to\s+)?(?:use|using|spawn|call|ask|start|launch|skip|run|spin(?:\s+up)?))\s+)?(?:(?:any|the|a|some|several|multiple|parallel|\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+)?(?:sub[- ]?agents?|agents?|workers?)\b|\b(?:do\s+not|don't|dont|never|avoid|without|no)\s+delegat(?:e|ion|ing)\b|\bwork\s+sequentially\b/i;
4903
+ EXPLICIT_YES = /\b(?:use|spawn|call|start|launch|ask|send|run|spin(?:\s+up)?|delegate\s+to)\s+(?:(?:some|several|multiple|parallel|\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+)?(?:sub[- ]?agents?|agents?|workers?)\b|\b(?:parallelize|split)\b.{0,50}\b(?:agents?|workers?|subtasks?)\b/i;
4904
4904
  SEQUENTIAL_DEPENDENCY = /\b(?:one\s+at\s+a\s+time|sequentially|in\s+sequence|in\s+dependency\s+order|tightly\s+coupled|same\s+(?:file|function|module)|first\b.{0,80}\bbefore\b|before\b.{0,80}\bthen\b|step[- ]by[- ]step)\b/i;
4905
4905
  CLEAR_PARALLEL = /\b(?:independent\s+(?:research|tasks?|questions?|work)|in\s+parallel|parallel\s+agents?|separately\s+investigate)\b/i;
4906
4906
  AMBIGUOUS_CANDIDATE = /\b(?:research|investigat\w*|audit|compare|contrast|review|comprehensive|across\s+(?:the\s+)?(?:codebase|repo|repository)|entire\s+(?:codebase|repo|repository)|multiple\s+(?:systems|modules|approaches|sources))\b/i;
@@ -13410,10 +13410,11 @@ async function loadAsterServerConfig(configPath3 = process.env.AUTOPILOT_ASTER_C
13410
13410
  }
13411
13411
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AsterConfigError();
13412
13412
  const record = raw;
13413
- if (!Array.isArray(record.models) || record.models.length === 0 || record.models.length > 100) {
13413
+ const modelCatalog = record.models === "openrouter" ? "openrouter" : "fixed";
13414
+ if (modelCatalog === "fixed" && (!Array.isArray(record.models) || record.models.length === 0 || record.models.length > 100)) {
13414
13415
  throw new AsterConfigError();
13415
13416
  }
13416
- const models = [...new Set(record.models)];
13417
+ const models = modelCatalog === "fixed" ? [...new Set(record.models)] : [];
13417
13418
  if (models.some((model) => typeof model !== "string" || !model.trim() || model.length > 200)) {
13418
13419
  throw new AsterConfigError();
13419
13420
  }
@@ -13451,7 +13452,7 @@ async function loadAsterServerConfig(configPath3 = process.env.AUTOPILOT_ASTER_C
13451
13452
  roots.push(rootPath);
13452
13453
  workspaces.push({ id: workspace.id, displayName: workspace.displayName.trim(), rootPath });
13453
13454
  }
13454
- return { models, workspaces, approvalTtlMs };
13455
+ return { models, modelCatalog, workspaces, approvalTtlMs };
13455
13456
  }
13456
13457
  function findAsterWorkspace(config2, workspaceId) {
13457
13458
  return config2.workspaces.find((workspace) => workspace.id === workspaceId);
@@ -13476,7 +13477,8 @@ var init_aster_workspaces = __esm({
13476
13477
  // src/server/aster-store.ts
13477
13478
  var aster_store_exports = {};
13478
13479
  __export(aster_store_exports, {
13479
- AsterStore: () => AsterStore
13480
+ AsterStore: () => AsterStore,
13481
+ defaultAsterDbPath: () => defaultAsterDbPath
13480
13482
  });
13481
13483
  import Database3 from "better-sqlite3";
13482
13484
  import { createHash as createHash3, randomBytes as randomBytes2, randomUUID as randomUUID5 } from "crypto";
@@ -13641,6 +13643,20 @@ var init_aster_store = __esm({
13641
13643
  UNIQUE(credential_id, create_key_hash)
13642
13644
  );
13643
13645
  CREATE INDEX IF NOT EXISTS idx_aster_cells_lifecycle ON aster_cell_mappings(status, updated_at);
13646
+ CREATE TABLE IF NOT EXISTS aster_github_grants (
13647
+ conversation_id TEXT PRIMARY KEY,
13648
+ token TEXT NOT NULL UNIQUE,
13649
+ repository TEXT NOT NULL,
13650
+ created_at INTEGER NOT NULL,
13651
+ revoked_at INTEGER
13652
+ );
13653
+ CREATE TABLE IF NOT EXISTS aster_conversation_projects (
13654
+ conversation_id TEXT PRIMARY KEY,
13655
+ kind TEXT NOT NULL,
13656
+ repository TEXT,
13657
+ branch TEXT,
13658
+ created_at INTEGER NOT NULL
13659
+ );
13644
13660
  `);
13645
13661
  try {
13646
13662
  chmodSync2(dbPath, 384);
@@ -13818,6 +13834,47 @@ var init_aster_store = __esm({
13818
13834
  });
13819
13835
  return update.immediate();
13820
13836
  }
13837
+ /** Records a conversation's project once; the first value wins so retries can't change it. */
13838
+ setConversationProject(conversationId, project) {
13839
+ this.db.prepare(`INSERT OR IGNORE INTO aster_conversation_projects (conversation_id, kind, repository, branch, created_at)
13840
+ VALUES (?, ?, ?, ?, ?)`).run(conversationId, project.kind, project.repository, project.branch, Date.now());
13841
+ }
13842
+ getConversationProject(conversationId) {
13843
+ const row = this.db.prepare("SELECT kind, repository, branch FROM aster_conversation_projects WHERE conversation_id = ?").get(conversationId);
13844
+ if (!row) return void 0;
13845
+ return { kind: row.kind === "code" ? "code" : "chat", repository: row.repository, branch: row.branch };
13846
+ }
13847
+ /**
13848
+ * The repository-scoped GitHub token for a code conversation, created once. It is stored
13849
+ * as-is (like the server's own GitHub token, it never leaves the server except into that
13850
+ * conversation's cell) so provisioning retries hand the cell the same value.
13851
+ */
13852
+ getOrCreateGitHubGrant(conversationId, repository) {
13853
+ const existing = this.db.prepare("SELECT token FROM aster_github_grants WHERE conversation_id = ? AND revoked_at IS NULL").get(conversationId);
13854
+ if (existing) return existing.token;
13855
+ const token = "aghp_" + randomBytes2(32).toString("base64url");
13856
+ this.db.prepare(`INSERT INTO aster_github_grants (conversation_id, token, repository, created_at) VALUES (?, ?, ?, ?)
13857
+ ON CONFLICT(conversation_id) DO UPDATE SET token = excluded.token, repository = excluded.repository, created_at = excluded.created_at, revoked_at = NULL`).run(conversationId, token, repository, Date.now());
13858
+ return token;
13859
+ }
13860
+ resolveGitHubGrant(token) {
13861
+ const row = this.db.prepare("SELECT conversation_id, repository FROM aster_github_grants WHERE token = ? AND revoked_at IS NULL").get(token);
13862
+ return row ? { conversationId: row.conversation_id, repository: row.repository } : void 0;
13863
+ }
13864
+ revokeGitHubGrant(conversationId) {
13865
+ this.db.prepare("UPDATE aster_github_grants SET revoked_at = ? WHERE conversation_id = ? AND revoked_at IS NULL").run(Date.now(), conversationId);
13866
+ }
13867
+ /** Switch an idle conversation's model; refused while a turn is active. */
13868
+ setConversationModel(conversationId, model) {
13869
+ const update = this.db.transaction(() => {
13870
+ const now2 = Date.now();
13871
+ const result2 = this.db.prepare("UPDATE aster_conversations SET model = ?, updated_at = ? WHERE id = ? AND active_run_id IS NULL").run(model, now2, conversationId);
13872
+ if (result2.changes !== 1) return false;
13873
+ this.insertEvent(conversationId, null, "model.changed", { model }, now2);
13874
+ return true;
13875
+ });
13876
+ return update.immediate();
13877
+ }
13821
13878
  appendEvent(conversationId, runId, type, data) {
13822
13879
  const append = this.db.transaction(() => this.insertEvent(conversationId, runId, type, data, Date.now()));
13823
13880
  return append.immediate();
@@ -14720,6 +14777,217 @@ var init_wake_scheduler = __esm({
14720
14777
  }
14721
14778
  });
14722
14779
 
14780
+ // src/models/openrouter-catalog.ts
14781
+ var openrouter_catalog_exports = {};
14782
+ __export(openrouter_catalog_exports, {
14783
+ DEFAULT_CACHE_TTL_MS: () => DEFAULT_CACHE_TTL_MS,
14784
+ ensureOpenRouterCatalog: () => ensureOpenRouterCatalog,
14785
+ fetchOpenRouterCatalog: () => fetchOpenRouterCatalog,
14786
+ loadOpenRouterCatalog: () => loadOpenRouterCatalog,
14787
+ mapOpenRouterModel: () => mapOpenRouterModel
14788
+ });
14789
+ import { readFile as readFile20, writeFile as writeFile12, mkdir as mkdir13 } from "fs/promises";
14790
+ import { homedir as homedir22 } from "os";
14791
+ import { join as join30, dirname as dirname15 } from "path";
14792
+ function catalogCachePath() {
14793
+ const xdg = process.env.XDG_CONFIG_HOME || join30(homedir22(), ".config");
14794
+ return join30(xdg, "kimiflare", "openrouter-models.json");
14795
+ }
14796
+ function perMtok(perToken) {
14797
+ const n = Number(perToken);
14798
+ return Number.isFinite(n) ? n * 1e6 : 0;
14799
+ }
14800
+ function mapOpenRouterModel(raw) {
14801
+ const supported = new Set(raw.supported_parameters ?? []);
14802
+ const aa = raw.benchmarks?.artificial_analysis;
14803
+ const quality = {
14804
+ ...typeof aa?.coding_index === "number" ? { coding: aa.coding_index } : {},
14805
+ ...typeof aa?.agentic_index === "number" ? { agentic: aa.agentic_index } : {},
14806
+ ...typeof aa?.intelligence_index === "number" ? { intelligence: aa.intelligence_index } : {}
14807
+ };
14808
+ return {
14809
+ id: raw.id,
14810
+ ...raw.name ? { name: raw.name } : {},
14811
+ ...typeof raw.created === "number" ? { created: raw.created } : {},
14812
+ ...Object.keys(quality).length > 0 ? { quality } : {},
14813
+ ...raw.supported_parameters ? { parameters: [...raw.supported_parameters] } : {},
14814
+ contextWindow: raw.context_length ?? 128e3,
14815
+ maxOutputTokens: raw.top_provider?.max_completion_tokens ?? 4096,
14816
+ pricing: {
14817
+ inputPerMtok: perMtok(raw.pricing?.prompt),
14818
+ ...raw.pricing?.input_cache_read ? { cachedInputPerMtok: perMtok(raw.pricing.input_cache_read) } : {},
14819
+ outputPerMtok: perMtok(raw.pricing?.completion)
14820
+ },
14821
+ supports: {
14822
+ // OpenRouter's own catalog is the authority on whether a model accepts tool
14823
+ // calls; absence of "tools" here means the upstream provider doesn't support
14824
+ // function calling for this model, and we must not claim otherwise.
14825
+ tools: supported.has("tools"),
14826
+ reasoning: supported.has("reasoning") || supported.has("include_reasoning"),
14827
+ streaming: true,
14828
+ vision: (raw.architecture?.input_modalities ?? []).includes("image"),
14829
+ temperature: supported.has("temperature") ? true : void 0
14830
+ }
14831
+ };
14832
+ }
14833
+ async function fetchOpenRouterCatalog(fetchImpl2 = fetch) {
14834
+ const res = await fetchWithNetworkRetry(fetchImpl2, openRouterUrl("models"), {
14835
+ headers: { Accept: "application/json" }
14836
+ });
14837
+ if (!res.ok) {
14838
+ throw new Error(`OpenRouter /models returned HTTP ${res.status}`);
14839
+ }
14840
+ const body = await res.json();
14841
+ return (body.data ?? []).map(mapOpenRouterModel);
14842
+ }
14843
+ async function readCache3() {
14844
+ try {
14845
+ const raw = await readFile20(catalogCachePath(), "utf8");
14846
+ const parsed = JSON.parse(raw);
14847
+ return parsed.version === CACHE_VERSION ? parsed : null;
14848
+ } catch {
14849
+ return null;
14850
+ }
14851
+ }
14852
+ async function writeCache3(models) {
14853
+ const path = catalogCachePath();
14854
+ try {
14855
+ await mkdir13(dirname15(path), { recursive: true });
14856
+ const cache = { version: CACHE_VERSION, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), models };
14857
+ await writeFile12(path, JSON.stringify(cache, null, 2), "utf8");
14858
+ } catch {
14859
+ }
14860
+ }
14861
+ async function loadOpenRouterCatalog(opts2 = {}) {
14862
+ const ttlMs = opts2.ttlMs ?? DEFAULT_CACHE_TTL_MS;
14863
+ const cached = await readCache3();
14864
+ const isFresh = cached && Date.now() - Date.parse(cached.fetchedAt) < ttlMs;
14865
+ if (isFresh) return cached.models;
14866
+ try {
14867
+ const models = await fetchOpenRouterCatalog(opts2.fetchImpl ?? fetch);
14868
+ await writeCache3(models);
14869
+ return models;
14870
+ } catch {
14871
+ return cached?.models ?? [];
14872
+ }
14873
+ }
14874
+ async function ensureOpenRouterCatalog(opts2 = {}) {
14875
+ try {
14876
+ const models = await loadOpenRouterCatalog(opts2);
14877
+ if (models.length > 0) registerOpenRouterModels(models);
14878
+ return models.length;
14879
+ } catch {
14880
+ return 0;
14881
+ }
14882
+ }
14883
+ var CACHE_VERSION, DEFAULT_CACHE_TTL_MS;
14884
+ var init_openrouter_catalog = __esm({
14885
+ "src/models/openrouter-catalog.ts"() {
14886
+ "use strict";
14887
+ init_registry();
14888
+ init_openrouter();
14889
+ CACHE_VERSION = 4;
14890
+ DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
14891
+ }
14892
+ });
14893
+
14894
+ // src/server/aster-github.ts
14895
+ function mapRepository(raw) {
14896
+ return {
14897
+ fullName: raw.full_name,
14898
+ name: raw.name,
14899
+ owner: raw.owner?.login ?? raw.full_name.split("/")[0] ?? "",
14900
+ private: raw.private,
14901
+ description: raw.description,
14902
+ defaultBranch: raw.default_branch ?? "main",
14903
+ updatedAt: raw.pushed_at ?? raw.updated_at ?? null
14904
+ };
14905
+ }
14906
+ var AsterGitHubError, REPOSITORY_FULL_NAME_RE, REPOSITORY_NAME_RE, API, AsterGitHub;
14907
+ var init_aster_github = __esm({
14908
+ "src/server/aster-github.ts"() {
14909
+ "use strict";
14910
+ AsterGitHubError = class extends Error {
14911
+ constructor(message2, status, code) {
14912
+ super(message2);
14913
+ this.status = status;
14914
+ this.code = code;
14915
+ this.name = "AsterGitHubError";
14916
+ }
14917
+ status;
14918
+ code;
14919
+ };
14920
+ REPOSITORY_FULL_NAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
14921
+ REPOSITORY_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/;
14922
+ API = "https://api.github.com";
14923
+ AsterGitHub = class {
14924
+ constructor(token = process.env.GITHUB_TOKEN, fetchImpl2 = fetch) {
14925
+ this.token = token;
14926
+ this.fetchImpl = fetchImpl2;
14927
+ }
14928
+ token;
14929
+ fetchImpl;
14930
+ get configured() {
14931
+ return Boolean(this.token?.trim());
14932
+ }
14933
+ /** Repositories the token can push to, most recently pushed first. */
14934
+ async listRepositories(query = "") {
14935
+ const raw = await this.request(
14936
+ "GET",
14937
+ "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member"
14938
+ );
14939
+ const needle = query.trim().toLowerCase();
14940
+ return raw.map(mapRepository).filter((repo) => !needle || repo.fullName.toLowerCase().includes(needle) || (repo.description ?? "").toLowerCase().includes(needle));
14941
+ }
14942
+ async getRepository(fullName) {
14943
+ if (!REPOSITORY_FULL_NAME_RE.test(fullName)) throw new AsterGitHubError("Invalid repository name", 400, "invalid_repository");
14944
+ return mapRepository(await this.request("GET", `/repos/${fullName}`));
14945
+ }
14946
+ /** Creates a repository under the token's user, initialized so it has a default branch to clone. */
14947
+ async createRepository(input) {
14948
+ if (!REPOSITORY_NAME_RE.test(input.name) || input.name === "." || input.name === "..") {
14949
+ throw new AsterGitHubError("Repository names may use letters, digits, '.', '_' and '-'", 400, "invalid_repository_name");
14950
+ }
14951
+ const description = input.description?.trim().slice(0, 350);
14952
+ return mapRepository(await this.request("POST", "/user/repos", {
14953
+ name: input.name,
14954
+ private: input.private,
14955
+ auto_init: true,
14956
+ ...description ? { description } : {}
14957
+ }));
14958
+ }
14959
+ async request(method, path, body) {
14960
+ if (!this.configured) throw new AsterGitHubError("GitHub is not connected on the server", 503, "github_not_configured");
14961
+ let response;
14962
+ try {
14963
+ response = await this.fetchImpl(API + path, {
14964
+ method,
14965
+ headers: {
14966
+ Accept: "application/vnd.github+json",
14967
+ Authorization: `Bearer ${this.token}`,
14968
+ "X-GitHub-Api-Version": "2022-11-28",
14969
+ "User-Agent": "autopilot-aster",
14970
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
14971
+ },
14972
+ body: body === void 0 ? void 0 : JSON.stringify(body),
14973
+ signal: AbortSignal.timeout(2e4)
14974
+ });
14975
+ } catch {
14976
+ throw new AsterGitHubError("GitHub could not be reached", 502, "github_unreachable");
14977
+ }
14978
+ if (response.ok) return await response.json();
14979
+ const detail = await response.json().catch(() => ({}));
14980
+ const message2 = detail.errors?.[0]?.message ?? detail.message ?? `GitHub returned HTTP ${response.status}`;
14981
+ if (response.status === 404) throw new AsterGitHubError("Repository not found or not accessible with the server's GitHub token", 404, "repository_not_found");
14982
+ if (response.status === 401) throw new AsterGitHubError("The server's GitHub token was rejected", 502, "github_token_rejected");
14983
+ if (response.status === 403) throw new AsterGitHubError(`GitHub refused the request: ${message2}`, 403, "github_forbidden");
14984
+ if (response.status === 422) throw new AsterGitHubError(message2, 422, "github_validation_failed");
14985
+ throw new AsterGitHubError(message2, 502, "github_error");
14986
+ }
14987
+ };
14988
+ }
14989
+ });
14990
+
14723
14991
  // src/server/aster-tools.ts
14724
14992
  import { realpath as realpath5 } from "fs/promises";
14725
14993
  import { isAbsolute as isAbsolute5, relative as relative7, sep as sep5 } from "path";
@@ -14785,11 +15053,18 @@ function posixNormalize(value) {
14785
15053
  }
14786
15054
  return "/" + out.join("/");
14787
15055
  }
15056
+ function isLikelySkKey(candidate) {
15057
+ if (/^sk-or-v1-[A-Za-z0-9]{16,}/.test(candidate)) return true;
15058
+ return candidate.slice(3).split(/[-_]/).some((chunk) => chunk.length >= 20 && /\d/.test(chunk));
15059
+ }
14788
15060
  function containsLikelyProviderSecret(value) {
14789
- return /(?:sk-or-(?:v1-)?[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{24,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|(?:CLOUDFLARE|OPENROUTER|REQUESTY)_API_(?:KEY|TOKEN)\s*=)/i.test(value);
15061
+ for (const match of value.matchAll(SK_CANDIDATE_RE)) {
15062
+ if (isLikelySkKey(match[0])) return true;
15063
+ }
15064
+ return /(?:\bgh[pousr]_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}|(?:CLOUDFLARE|OPENROUTER|REQUESTY)_API_(?:KEY|TOKEN)\s*=)/.test(value);
14790
15065
  }
14791
15066
  function redactLikelySecrets(value) {
14792
- return value.replace(/sk-or-(?:v1-)?[A-Za-z0-9_-]{16,}/g, "[REDACTED_PROVIDER_SECRET]").replace(/sk-[A-Za-z0-9_-]{24,}/g, "[REDACTED_PROVIDER_SECRET]").replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED_PROVIDER_SECRET]").replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED_PROVIDER_SECRET]");
15067
+ return value.replace(SK_CANDIDATE_RE, (match) => isLikelySkKey(match) ? "[REDACTED_PROVIDER_SECRET]" : match).replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED_PROVIDER_SECRET]").replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED_PROVIDER_SECRET]");
14793
15068
  }
14794
15069
  function assertInside(root, candidate) {
14795
15070
  const rel = relative7(root, candidate);
@@ -14801,7 +15076,7 @@ function assertNotSensitive(root, candidate) {
14801
15076
  throw new Error("workspace_sensitive_path_forbidden");
14802
15077
  }
14803
15078
  }
14804
- var ASTR_TOOLS, SENSITIVE_NAMES;
15079
+ var ASTR_TOOLS, SENSITIVE_NAMES, SK_CANDIDATE_RE;
14805
15080
  var init_aster_tools = __esm({
14806
15081
  "src/server/aster-tools.ts"() {
14807
15082
  "use strict";
@@ -14824,11 +15099,519 @@ var init_aster_tools = __esm({
14824
15099
  "credentials",
14825
15100
  "secrets"
14826
15101
  ]);
15102
+ SK_CANDIDATE_RE = /(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{16,}/g;
14827
15103
  }
14828
15104
  });
14829
15105
 
14830
- // src/server/aster-api.ts
15106
+ // src/server/aster-memory.ts
15107
+ import Database4 from "better-sqlite3";
14831
15108
  import { randomUUID as randomUUID6 } from "crypto";
15109
+ import { chmodSync as chmodSync3, mkdirSync as mkdirSync7 } from "fs";
15110
+ import { dirname as dirname16 } from "path";
15111
+ function rowToItem(row) {
15112
+ return {
15113
+ id: row.id,
15114
+ kind: row.kind,
15115
+ key: row.key,
15116
+ title: row.title,
15117
+ content: row.content,
15118
+ pinned: row.pinned === 1,
15119
+ sourceConversationId: row.source_conversation_id,
15120
+ createdAt: row.created_at,
15121
+ updatedAt: row.updated_at
15122
+ };
15123
+ }
15124
+ function normalizeKey(kind, value) {
15125
+ const raw = value.toLowerCase().startsWith(`${kind}:`) ? value.slice(kind.length + 1) : value;
15126
+ const slug = raw.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
15127
+ const key = `${kind}:${slug}`;
15128
+ return KEY_RE.test(key) ? key : null;
15129
+ }
15130
+ function formatCard(card) {
15131
+ return `- ${card.title} (${card.kind}): ${card.content}`;
15132
+ }
15133
+ function mentions(text, card) {
15134
+ const haystack = ` ${text.toLowerCase()} `;
15135
+ const words = /* @__PURE__ */ new Set();
15136
+ for (const part of [card.title, card.key?.split(":")[1]?.replace(/-/g, " ") ?? ""]) {
15137
+ for (const word of part.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
15138
+ if (word.length >= 3 && !STOP_WORDS.has(word)) words.add(word);
15139
+ }
15140
+ }
15141
+ return [...words].some((word) => new RegExp(`[^\\p{L}\\p{N}]${escapeRegExp(word)}[^\\p{L}\\p{N}]`, "u").test(haystack));
15142
+ }
15143
+ function escapeRegExp(value) {
15144
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15145
+ }
15146
+ var MAX_CARD_CHARS, MAX_FACT_CHARS, MAX_MEMORY_PROMPT_ITEMS, BRIEF_FACTS, BRIEF_RECENT_SESSIONS, BRIEF_RECENT_CARDS, KEY_RE, AsterMemory, EXTRACTION_PROMPT, STOP_WORDS;
15147
+ var init_aster_memory = __esm({
15148
+ "src/server/aster-memory.ts"() {
15149
+ "use strict";
15150
+ init_openrouter();
15151
+ init_aster_store();
15152
+ init_aster_tools();
15153
+ MAX_CARD_CHARS = 900;
15154
+ MAX_FACT_CHARS = 300;
15155
+ MAX_MEMORY_PROMPT_ITEMS = 150;
15156
+ BRIEF_FACTS = 15;
15157
+ BRIEF_RECENT_SESSIONS = 6;
15158
+ BRIEF_RECENT_CARDS = 3;
15159
+ KEY_RE = /^(person|project):[a-z0-9][a-z0-9-]{0,80}$/;
15160
+ AsterMemory = class {
15161
+ constructor(dbPath = defaultAsterDbPath(), options = {}) {
15162
+ this.options = options;
15163
+ mkdirSync7(dirname16(dbPath), { recursive: true, mode: 448 });
15164
+ this.db = new Database4(dbPath);
15165
+ this.db.pragma("journal_mode = WAL");
15166
+ this.db.pragma("busy_timeout = 5000");
15167
+ this.db.exec(`
15168
+ CREATE TABLE IF NOT EXISTS aster_memories (
15169
+ id TEXT PRIMARY KEY,
15170
+ kind TEXT NOT NULL,
15171
+ key TEXT,
15172
+ title TEXT NOT NULL,
15173
+ content TEXT NOT NULL,
15174
+ pinned INTEGER NOT NULL DEFAULT 0,
15175
+ source_conversation_id TEXT,
15176
+ created_at INTEGER NOT NULL,
15177
+ updated_at INTEGER NOT NULL,
15178
+ deleted INTEGER NOT NULL DEFAULT 0
15179
+ );
15180
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_aster_memories_key ON aster_memories(key) WHERE key IS NOT NULL AND deleted = 0;
15181
+ CREATE INDEX IF NOT EXISTS idx_aster_memories_kind ON aster_memories(kind, deleted, updated_at);
15182
+ `);
15183
+ try {
15184
+ chmodSync3(dbPath, 384);
15185
+ } catch {
15186
+ }
15187
+ }
15188
+ options;
15189
+ db;
15190
+ /** Card keys already shown to the agent per conversation, so later turns only add new ones. */
15191
+ shown = /* @__PURE__ */ new Map();
15192
+ get enabled() {
15193
+ return this.options.enabled !== false && Boolean(this.apiKey);
15194
+ }
15195
+ get apiKey() {
15196
+ return this.options.apiKey ?? process.env.OPENROUTER_API_KEY;
15197
+ }
15198
+ get model() {
15199
+ return this.options.model ?? process.env.AUTOPILOT_ASTER_MEMORY_MODEL ?? "google/gemini-3.8-flash";
15200
+ }
15201
+ close() {
15202
+ this.db.close();
15203
+ }
15204
+ list(kind) {
15205
+ const rows = kind ? this.db.prepare("SELECT * FROM aster_memories WHERE deleted = 0 AND kind = ? ORDER BY updated_at DESC").all(kind) : this.db.prepare("SELECT * FROM aster_memories WHERE deleted = 0 ORDER BY updated_at DESC").all();
15206
+ return rows.map(rowToItem);
15207
+ }
15208
+ get(id) {
15209
+ const row = this.db.prepare("SELECT * FROM aster_memories WHERE id = ? AND deleted = 0").get(id);
15210
+ return row ? rowToItem(row) : void 0;
15211
+ }
15212
+ /** Soft delete; the user removed it, so extraction must not silently recreate it from this item. */
15213
+ delete(id) {
15214
+ return this.db.prepare("UPDATE aster_memories SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0").run(Date.now(), id).changes === 1;
15215
+ }
15216
+ /** Write or merge one item. person/project/session are keyed; facts are by id. */
15217
+ upsert(input) {
15218
+ const now2 = Date.now();
15219
+ const limit = input.kind === "fact" ? MAX_FACT_CHARS : MAX_CARD_CHARS;
15220
+ const title = input.title.trim().slice(0, 120);
15221
+ const content = input.content.trim().slice(0, limit);
15222
+ const existing = input.key ? this.db.prepare("SELECT * FROM aster_memories WHERE key = ? AND deleted = 0").get(input.key) : input.id ? this.db.prepare("SELECT * FROM aster_memories WHERE id = ? AND deleted = 0").get(input.id) : void 0;
15223
+ if (existing) {
15224
+ this.db.prepare("UPDATE aster_memories SET title = ?, content = ?, pinned = ?, updated_at = ? WHERE id = ?").run(title, content, existing.pinned || input.pinned ? 1 : 0, now2, existing.id);
15225
+ return { item: this.get(existing.id), change: "updated" };
15226
+ }
15227
+ const id = randomUUID6();
15228
+ this.db.prepare(`INSERT INTO aster_memories (id, kind, key, title, content, pinned, source_conversation_id, created_at, updated_at)
15229
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.kind, input.key ?? null, title, content, input.pinned ? 1 : 0, input.sourceConversationId ?? null, now2, now2);
15230
+ return { item: this.get(id), change: "added" };
15231
+ }
15232
+ /**
15233
+ * The memory note for a turn: a full brief on a conversation's first turn, afterwards only
15234
+ * person/project cards the user's message newly brings up. Empty when there is nothing new.
15235
+ */
15236
+ brief(conversationId, userText) {
15237
+ const items = this.list();
15238
+ if (items.length === 0) return "";
15239
+ const shown = this.shown.get(conversationId);
15240
+ const cards = items.filter((item) => item.kind === "person" || item.kind === "project");
15241
+ const mentioned = cards.filter((card) => mentions(userText, card));
15242
+ if (shown) {
15243
+ const fresh = mentioned.filter((card) => !shown.has(card.id));
15244
+ if (fresh.length === 0) return "";
15245
+ fresh.forEach((card) => shown.add(card.id));
15246
+ return `[Aster memory \u2014 relevant to this message]
15247
+ ${fresh.map(formatCard).join("\n")}`;
15248
+ }
15249
+ const shownNow = /* @__PURE__ */ new Set();
15250
+ const facts = items.filter((item) => item.kind === "fact").sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt).slice(0, BRIEF_FACTS);
15251
+ const featured = [...mentioned, ...cards.filter((card) => !mentioned.includes(card)).slice(0, BRIEF_RECENT_CARDS)];
15252
+ featured.forEach((card) => shownNow.add(card.id));
15253
+ const others = cards.filter((card) => !shownNow.has(card.id));
15254
+ const sessions = items.filter((item) => item.kind === "session" && item.key !== conversationId).slice(0, BRIEF_RECENT_SESSIONS);
15255
+ this.shown.set(conversationId, shownNow);
15256
+ const sections = [
15257
+ "[Aster memory \u2014 what you know about the user from earlier chats. Use it naturally and don't recite it. The full memory is in /workspace/.aster/memory.md; grep it when the user mentions someone or something you don't recognize.]"
15258
+ ];
15259
+ if (facts.length) sections.push("About the user:\n" + facts.map((fact) => `- ${fact.content}`).join("\n"));
15260
+ if (featured.length) sections.push("People and projects:\n" + featured.map(formatCard).join("\n"));
15261
+ if (others.length) sections.push("Also known (details in memory.md): " + others.map((card) => card.title).join(", "));
15262
+ if (sessions.length) sections.push("Recent chats:\n" + sessions.map((session) => `- ${session.title}: ${session.content}`).join("\n"));
15263
+ return sections.join("\n\n");
15264
+ }
15265
+ /** All memory as Markdown, written into the cell so the agent can search it on demand. */
15266
+ snapshot() {
15267
+ const items = this.list();
15268
+ const group = (kind, heading, render2) => {
15269
+ const entries = items.filter((item) => item.kind === kind);
15270
+ return entries.length ? `## ${heading}
15271
+
15272
+ ${entries.map(render2).join("\n")}
15273
+ ` : "";
15274
+ };
15275
+ return [
15276
+ "# Aster memory\n\nWhat the user's assistant remembers across chats. Read-only here; it is updated automatically after each turn.\n",
15277
+ group("fact", "About the user", (item) => `- ${item.content}`),
15278
+ group("person", "People", (item) => `### ${item.title}
15279
+ ${item.content}
15280
+ `),
15281
+ group("project", "Projects", (item) => `### ${item.title}
15282
+ ${item.content}
15283
+ `),
15284
+ group("session", "Chats", (item) => `- **${item.title}** (${new Date(item.updatedAt).toISOString().slice(0, 10)}): ${item.content}`)
15285
+ ].filter(Boolean).join("\n");
15286
+ }
15287
+ /** Read one exchange and update memory. Returns what changed (for the app's "memory updated" chip). */
15288
+ async remember(exchange) {
15289
+ if (!this.enabled) return [];
15290
+ const result2 = await this.extract(exchange);
15291
+ if (!result2) return [];
15292
+ const changes = [];
15293
+ const known = new Map(this.list().filter((item) => item.kind !== "session").map((item) => [item.id, item]));
15294
+ for (const id of result2.deletes ?? []) {
15295
+ const item = known.get(id);
15296
+ if (item && !item.pinned && this.delete(id)) changes.push({ id, kind: item.kind, title: item.title, change: "removed" });
15297
+ }
15298
+ for (const upsert of result2.upserts ?? []) {
15299
+ const kind = upsert.kind === "person" || upsert.kind === "project" || upsert.kind === "fact" ? upsert.kind : void 0;
15300
+ const title = upsert.title?.trim();
15301
+ const content = upsert.content?.trim();
15302
+ if (!kind || !title || !content || containsLikelyProviderSecret(content)) continue;
15303
+ const key = kind === "fact" ? null : normalizeKey(kind, upsert.key ?? title);
15304
+ if (kind !== "fact" && !key) continue;
15305
+ const id = kind === "fact" && upsert.id && known.has(upsert.id) ? upsert.id : null;
15306
+ const { item, change } = this.upsert({ kind, key, id, title, content, pinned: upsert.pinned === true, sourceConversationId: exchange.conversationId });
15307
+ changes.push({ id: item.id, kind, title: item.title, change });
15308
+ }
15309
+ const session = result2.session;
15310
+ if (session?.title?.trim() && session.summary?.trim()) {
15311
+ const repo = exchange.project?.kind === "code" && exchange.project.repository ? ` [${exchange.project.repository}]` : "";
15312
+ this.upsert({
15313
+ kind: "session",
15314
+ key: exchange.conversationId,
15315
+ title: session.title.trim().slice(0, 80) + repo,
15316
+ content: session.summary.trim(),
15317
+ sourceConversationId: exchange.conversationId
15318
+ });
15319
+ }
15320
+ return changes;
15321
+ }
15322
+ async extract(exchange) {
15323
+ const items = this.list().filter((item) => item.kind !== "session").sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt).slice(0, MAX_MEMORY_PROMPT_ITEMS);
15324
+ const currentSession = this.list("session").find((item) => item.key === exchange.conversationId);
15325
+ const memory = items.length ? items.map((item) => JSON.stringify({ id: item.id, kind: item.kind, key: item.key, title: item.title, content: item.content, pinned: item.pinned })).join("\n") : "(empty)";
15326
+ const context = exchange.project?.kind === "code" && exchange.project.repository ? `Code chat in GitHub repository ${exchange.project.repository}.` : "General chat.";
15327
+ const user = [
15328
+ `CURRENT MEMORY (one JSON object per line):
15329
+ ${memory}`,
15330
+ `THIS CHAT: ${context}${currentSession ? ` Summary so far: ${currentSession.content}` : ""}`,
15331
+ `LATEST USER MESSAGE:
15332
+ ${exchange.userText.slice(0, 6e3)}`,
15333
+ `ASSISTANT REPLY:
15334
+ ${exchange.assistantText.slice(0, 4e3)}`
15335
+ ].join("\n\n");
15336
+ let response;
15337
+ try {
15338
+ response = await (this.options.fetchImpl ?? fetch)(openRouterUrl("chat/completions"), {
15339
+ method: "POST",
15340
+ headers: { ...openRouterHeaders(this.apiKey), "Content-Type": "application/json" },
15341
+ body: JSON.stringify({
15342
+ model: this.model,
15343
+ temperature: 0,
15344
+ response_format: { type: "json_object" },
15345
+ messages: [{ role: "system", content: EXTRACTION_PROMPT }, { role: "user", content: user }]
15346
+ }),
15347
+ signal: AbortSignal.timeout(3e4)
15348
+ });
15349
+ } catch {
15350
+ return void 0;
15351
+ }
15352
+ if (!response.ok) return void 0;
15353
+ const body = await response.json().catch(() => void 0);
15354
+ const text = body?.choices?.[0]?.message?.content;
15355
+ if (!text) return void 0;
15356
+ try {
15357
+ return JSON.parse(text.replace(/^```(?:json)?\s*|\s*```$/g, ""));
15358
+ } catch {
15359
+ return void 0;
15360
+ }
15361
+ }
15362
+ };
15363
+ EXTRACTION_PROMPT = `You maintain the long-term memory of one person (the user) for their AI assistant, Aster. The user has many separate chats; memory is how a new chat already knows their world. Read the latest exchange against the current memory and return JSON operations.
15364
+
15365
+ Remember what will matter in future, unrelated chats:
15366
+ - PEOPLE the user deals with: name, role, organization, relationship to the user, which projects they are part of, anything the user said about working with them.
15367
+ - PROJECTS: name, what it is, client or owner, goals, scope, stack, repository, status, key decisions, and ideas the user brainstormed or chose.
15368
+ - FACTS about the user: durable preferences, circumstances (job, freelance work, location if stated), ways of working, tools they use.
15369
+
15370
+ Do not store: one-off task details, things only relevant inside this chat, general knowledge, the assistant's own suggestions the user did not adopt, credentials or secrets, and sensitive personal details (health, finances, etc.) unless the user explicitly asks you to remember them.
15371
+
15372
+ Rules:
15373
+ - If the user explicitly asks to remember something, you MUST store it, with "pinned": true.
15374
+ - Prefer updating an existing card over creating a new one. To update a person or project, reuse its exact "key" and write the complete new content, merging old and new information (max ~120 words).
15375
+ - Keys: "person:<first-last>" or "project:<short-name>", lowercase with hyphens.
15376
+ - Facts are one sentence. To change a fact, return it with its "id" and new content; to remove an outdated or contradicted fact, put its id in "deletes".
15377
+ - Most exchanges contain nothing worth remembering: return empty "upserts" then. Never invent details.
15378
+ - Always write "session": a short title (max 8 words) and a 1-2 sentence summary of what this chat is about and where it stands, updated with the latest exchange.
15379
+
15380
+ Return only JSON:
15381
+ {"upserts":[{"kind":"person|project|fact","key":"person:...|project:...|null","id":"existing fact id|null","title":"Display name","content":"...","pinned":false}],"deletes":["id"],"session":{"title":"...","summary":"..."}}`;
15382
+ STOP_WORDS = /* @__PURE__ */ new Set(["the", "and", "for", "app", "project", "new", "with", "website", "site"]);
15383
+ }
15384
+ });
15385
+
15386
+ // src/server/aster-github-proxy.ts
15387
+ import { createServer } from "http";
15388
+ import { request as httpsRequest } from "https";
15389
+ import { request as httpRequest } from "http";
15390
+ function extractToken(req) {
15391
+ const header = req.headers.authorization;
15392
+ if (!header) return void 0;
15393
+ const [scheme, value = ""] = header.split(" ", 2);
15394
+ if (/^(bearer|token)$/i.test(scheme ?? "")) return value.trim() || void 0;
15395
+ if (/^basic$/i.test(scheme ?? "")) {
15396
+ const decoded = Buffer.from(value, "base64").toString("utf8");
15397
+ const password = decoded.slice(decoded.indexOf(":") + 1);
15398
+ return password || void 0;
15399
+ }
15400
+ return void 0;
15401
+ }
15402
+ function deny(res, status, message2) {
15403
+ res.writeHead(status, { "content-type": "application/json" });
15404
+ res.end(JSON.stringify({ message: message2 }));
15405
+ }
15406
+ var GIT_PATH_RE, API_PATH_RE, FORWARDED_REQUEST_HEADERS, HOP_BY_HOP, AsterGitHubProxy;
15407
+ var init_aster_github_proxy = __esm({
15408
+ "src/server/aster-github-proxy.ts"() {
15409
+ "use strict";
15410
+ GIT_PATH_RE = /^\/git\/([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
15411
+ API_PATH_RE = /^\/api\/repos\/([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})(\/.*)?$/;
15412
+ FORWARDED_REQUEST_HEADERS = ["accept", "content-type", "content-encoding", "content-length", "git-protocol", "user-agent", "x-github-api-version"];
15413
+ HOP_BY_HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer"]);
15414
+ AsterGitHubProxy = class {
15415
+ constructor(options) {
15416
+ this.options = options;
15417
+ this.gitUpstream = new URL(options.gitUpstream ?? "https://github.com");
15418
+ this.apiUpstream = new URL(options.apiUpstream ?? "https://api.github.com");
15419
+ }
15420
+ options;
15421
+ server;
15422
+ gitUpstream;
15423
+ apiUpstream;
15424
+ async listen(port, host) {
15425
+ this.server = createServer((req, res) => this.handle(req, res));
15426
+ await new Promise((resolve15, reject) => {
15427
+ this.server.once("error", reject);
15428
+ this.server.listen(port, host, () => resolve15());
15429
+ });
15430
+ const address = this.server.address();
15431
+ return typeof address === "object" && address ? address.port : port;
15432
+ }
15433
+ close() {
15434
+ this.server?.close();
15435
+ this.server = void 0;
15436
+ }
15437
+ handle(req, res) {
15438
+ const url = new URL(req.url ?? "/", "http://proxy.local");
15439
+ const git2 = url.pathname.match(GIT_PATH_RE);
15440
+ const api = git2 ? null : url.pathname.match(API_PATH_RE);
15441
+ if (!git2 && !api) return deny(res, 404, "Only this conversation's repository is reachable");
15442
+ const grantToken = extractToken(req);
15443
+ if (!grantToken) {
15444
+ res.writeHead(401, { "www-authenticate": 'Basic realm="aster-github"', "content-type": "text/plain" });
15445
+ res.end("credentials required");
15446
+ return;
15447
+ }
15448
+ const grant = this.options.resolveGrant(grantToken);
15449
+ if (!grant) return deny(res, 403, "This repository grant is invalid or was revoked");
15450
+ const [, owner, name] = git2 ?? api;
15451
+ if (`${owner}/${name}`.toLowerCase() !== grant.repository.toLowerCase()) {
15452
+ return deny(res, 403, `This conversation can only access ${grant.repository}`);
15453
+ }
15454
+ const upstream = git2 ? new URL(`/${owner}/${name}.git/${git2[3]}${url.search}`, this.gitUpstream) : new URL(`/repos/${owner}/${name}${api[3] ?? ""}${url.search}`, this.apiUpstream);
15455
+ const headers = {};
15456
+ for (const name2 of FORWARDED_REQUEST_HEADERS) {
15457
+ const value = req.headers[name2];
15458
+ if (typeof value === "string") headers[name2] = value;
15459
+ }
15460
+ headers["user-agent"] ??= "autopilot-aster";
15461
+ headers.authorization = git2 ? "Basic " + Buffer.from(`x-access-token:${this.options.token}`).toString("base64") : `Bearer ${this.options.token}`;
15462
+ const send = upstream.protocol === "https:" ? httpsRequest : httpRequest;
15463
+ const forward = send(upstream, { method: req.method, headers }, (upstreamRes) => {
15464
+ const responseHeaders = {};
15465
+ for (const [key, value] of Object.entries(upstreamRes.headers)) {
15466
+ if (value !== void 0 && !HOP_BY_HOP.has(key)) responseHeaders[key] = value;
15467
+ }
15468
+ res.writeHead(upstreamRes.statusCode ?? 502, responseHeaders);
15469
+ upstreamRes.pipe(res);
15470
+ });
15471
+ forward.on("error", () => {
15472
+ if (!res.headersSent) deny(res, 502, "GitHub could not be reached");
15473
+ else res.destroy();
15474
+ });
15475
+ req.pipe(forward);
15476
+ }
15477
+ };
15478
+ }
15479
+ });
15480
+
15481
+ // src/server/aster-push.ts
15482
+ import Database5 from "better-sqlite3";
15483
+ import { createSign } from "crypto";
15484
+ import { readFileSync as readFileSync5 } from "fs";
15485
+ import { connect } from "http2";
15486
+ function truncate2(value, limit) {
15487
+ const clean = value.replace(/\s+/g, " ").trim();
15488
+ return clean.length > limit ? clean.slice(0, limit - 1) + "\u2026" : clean;
15489
+ }
15490
+ var DEVICE_TOKEN_RE, AsterPush;
15491
+ var init_aster_push = __esm({
15492
+ "src/server/aster-push.ts"() {
15493
+ "use strict";
15494
+ init_aster_store();
15495
+ DEVICE_TOKEN_RE = /^[0-9a-f]{64,200}$/i;
15496
+ AsterPush = class {
15497
+ db;
15498
+ options;
15499
+ privateKey;
15500
+ jwt;
15501
+ sessions = /* @__PURE__ */ new Map();
15502
+ constructor(dbPath = defaultAsterDbPath(), options = {}, env2 = process.env) {
15503
+ this.options = {
15504
+ ...options,
15505
+ keyPath: options.keyPath ?? env2.AUTOPILOT_ASTER_APNS_KEY_PATH,
15506
+ keyId: options.keyId ?? env2.AUTOPILOT_ASTER_APNS_KEY_ID ?? "",
15507
+ teamId: options.teamId ?? env2.AUTOPILOT_ASTER_APNS_TEAM_ID ?? "",
15508
+ topic: options.topic ?? env2.AUTOPILOT_ASTER_APNS_TOPIC ?? "com.aster.ai"
15509
+ };
15510
+ this.db = new Database5(dbPath);
15511
+ this.db.pragma("journal_mode = WAL");
15512
+ this.db.pragma("busy_timeout = 5000");
15513
+ this.db.exec(`
15514
+ CREATE TABLE IF NOT EXISTS aster_devices (
15515
+ token TEXT PRIMARY KEY,
15516
+ environment TEXT NOT NULL,
15517
+ created_at INTEGER NOT NULL,
15518
+ updated_at INTEGER NOT NULL
15519
+ );
15520
+ `);
15521
+ }
15522
+ /** Whether an APNs key is configured; devices are still recorded without one. */
15523
+ get configured() {
15524
+ return Boolean((this.options.keyPath || this.options.deliver) && this.options.keyId && this.options.teamId);
15525
+ }
15526
+ register(token, environment) {
15527
+ if (!DEVICE_TOKEN_RE.test(token)) return false;
15528
+ const now2 = Date.now();
15529
+ this.db.prepare(`INSERT INTO aster_devices (token, environment, created_at, updated_at) VALUES (?, ?, ?, ?)
15530
+ ON CONFLICT(token) DO UPDATE SET environment = excluded.environment, updated_at = excluded.updated_at`).run(token.toLowerCase(), environment, now2, now2);
15531
+ return true;
15532
+ }
15533
+ unregister(token) {
15534
+ this.db.prepare("DELETE FROM aster_devices WHERE token = ?").run(token.toLowerCase());
15535
+ }
15536
+ deviceCount() {
15537
+ return this.db.prepare("SELECT COUNT(*) AS n FROM aster_devices").get().n;
15538
+ }
15539
+ /** Sends to every registered device; tokens Apple reports as gone are removed. Never throws. */
15540
+ async notify(message2) {
15541
+ if (!this.configured) return;
15542
+ const devices = this.db.prepare("SELECT token, environment FROM aster_devices").all();
15543
+ if (devices.length === 0) return;
15544
+ const payload = JSON.stringify({
15545
+ aps: {
15546
+ alert: { title: truncate2(message2.title, 80), body: truncate2(message2.body, 180) },
15547
+ sound: "default",
15548
+ "thread-id": message2.conversationId
15549
+ },
15550
+ conversationId: message2.conversationId
15551
+ });
15552
+ await Promise.all(devices.map(async (device) => {
15553
+ const host = device.environment === "development" ? "api.sandbox.push.apple.com" : "api.push.apple.com";
15554
+ try {
15555
+ const status = await this.send(host, device.token, payload);
15556
+ if (status === 410 || status === 400) this.unregister(device.token);
15557
+ } catch {
15558
+ }
15559
+ }));
15560
+ }
15561
+ close() {
15562
+ for (const session of this.sessions.values()) session.close();
15563
+ this.sessions.clear();
15564
+ this.db.close();
15565
+ }
15566
+ async send(host, token, payload) {
15567
+ const headers = {
15568
+ authorization: `bearer ${this.token()}`,
15569
+ "apns-topic": this.options.topic,
15570
+ "apns-push-type": "alert",
15571
+ "apns-priority": "10"
15572
+ };
15573
+ if (this.options.deliver) return this.options.deliver({ host, token, headers, payload });
15574
+ const session = this.session(host);
15575
+ return await new Promise((resolve15, reject) => {
15576
+ const stream = session.request({ ":method": "POST", ":path": `/3/device/${token}`, ...headers, "content-type": "application/json" });
15577
+ let status = 0;
15578
+ stream.setTimeout(15e3, () => stream.close());
15579
+ stream.on("response", (responseHeaders) => {
15580
+ status = Number(responseHeaders[":status"]) || 0;
15581
+ });
15582
+ stream.on("error", reject);
15583
+ stream.on("close", () => resolve15(status));
15584
+ stream.resume();
15585
+ stream.end(payload);
15586
+ });
15587
+ }
15588
+ session(host) {
15589
+ const existing = this.sessions.get(host);
15590
+ if (existing && !existing.closed && !existing.destroyed) return existing;
15591
+ const session = connect(`https://${host}`);
15592
+ session.on("error", () => this.sessions.delete(host));
15593
+ session.on("close", () => this.sessions.delete(host));
15594
+ session.unref();
15595
+ this.sessions.set(host, session);
15596
+ return session;
15597
+ }
15598
+ /** Provider token (ES256 JWT); Apple accepts one for up to an hour, so it is reused for 50 minutes. */
15599
+ token() {
15600
+ const now2 = Math.floor(Date.now() / 1e3);
15601
+ if (this.jwt && now2 - this.jwt.issuedAt < 50 * 60) return this.jwt.value;
15602
+ this.privateKey ??= readFileSync5(this.options.keyPath, "utf8");
15603
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
15604
+ const unsigned = `${encode({ alg: "ES256", kid: this.options.keyId })}.${encode({ iss: this.options.teamId, iat: now2 })}`;
15605
+ const signature = createSign("SHA256").update(unsigned).sign({ key: this.privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
15606
+ this.jwt = { value: `${unsigned}.${signature}`, issuedAt: now2 };
15607
+ return this.jwt.value;
15608
+ }
15609
+ };
15610
+ }
15611
+ });
15612
+
15613
+ // src/server/aster-api.ts
15614
+ import { randomUUID as randomUUID7 } from "crypto";
14832
15615
  import { unlink as unlink2 } from "fs/promises";
14833
15616
  import { resolve as resolve8 } from "path";
14834
15617
  function parseCursor(header, query) {
@@ -14837,8 +15620,23 @@ function parseCursor(header, query) {
14837
15620
  const cursor = Number(value);
14838
15621
  return Number.isSafeInteger(cursor) ? cursor : void 0;
14839
15622
  }
14840
- function publicConversation(conversation) {
15623
+ function collectAssistantText(store, conversationId, runId, after) {
15624
+ let text = "";
15625
+ let cursor = after;
15626
+ for (; ; ) {
15627
+ const events = store.eventsAfter(conversationId, cursor, 500);
15628
+ if (events.length === 0) break;
15629
+ for (const event of events) {
15630
+ if (event.runId === runId && event.type === "assistant.delta" && typeof event.data.text === "string") text += event.data.text;
15631
+ }
15632
+ cursor = events.at(-1).sequence;
15633
+ if (text.length > 2e4) break;
15634
+ }
15635
+ return text;
15636
+ }
15637
+ function publicConversation(conversation, project) {
14841
15638
  return {
15639
+ ...project ? { kind: project.kind, repository: project.repository, branch: project.branch } : {},
14842
15640
  conversationId: conversation.id,
14843
15641
  workspaceId: conversation.workspaceId,
14844
15642
  model: conversation.model,
@@ -14862,6 +15660,25 @@ function publicApproval(approval) {
14862
15660
  decision: approval.decision
14863
15661
  };
14864
15662
  }
15663
+ async function selectableModels(config2) {
15664
+ if (config2.modelCatalog === "fixed") return config2.models.map((id) => ({ id }));
15665
+ const catalog = await loadOpenRouterCatalog();
15666
+ return catalog.filter((model) => model.supports.tools).map((model) => ({
15667
+ id: model.id,
15668
+ ...model.name ? { name: model.name } : {},
15669
+ contextWindow: model.contextWindow,
15670
+ inputPerMtok: model.pricing.inputPerMtok,
15671
+ outputPerMtok: model.pricing.outputPerMtok,
15672
+ ...model.created !== void 0 ? { created: model.created } : {},
15673
+ reasoning: model.supports.reasoning,
15674
+ vision: model.supports.vision === true
15675
+ }));
15676
+ }
15677
+ async function isSelectableModel(config2, model) {
15678
+ if (!model.trim() || model.length > 200) return false;
15679
+ if (config2.modelCatalog === "fixed") return config2.models.includes(model);
15680
+ return (await selectableModels(config2)).some((entry) => entry.id === model);
15681
+ }
14865
15682
  async function readJsonBody(req) {
14866
15683
  const chunks = [];
14867
15684
  let bytes = 0;
@@ -14903,10 +15720,15 @@ var init_aster_api = __esm({
14903
15720
  init_version();
14904
15721
  init_aster_store();
14905
15722
  init_aster_workspaces();
15723
+ init_openrouter_catalog();
15724
+ init_aster_github();
15725
+ init_aster_memory();
15726
+ init_aster_github_proxy();
15727
+ init_aster_push();
14906
15728
  init_aster_tools();
14907
15729
  API_PREFIX = "/api/v1";
14908
- MAX_BODY_BYTES = 64 * 1024;
14909
- MAX_USER_TURN_CHARS = 2e4;
15730
+ MAX_BODY_BYTES = 4 * 1024 * 1024;
15731
+ MAX_USER_TURN_CHARS = 5e5;
14910
15732
  CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
14911
15733
  CREATE_IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
14912
15734
  MAX_APPROVAL_ARG_BYTES = 24 * 1024;
@@ -14918,9 +15740,57 @@ var init_aster_api = __esm({
14918
15740
  waiters = /* @__PURE__ */ new Map();
14919
15741
  runtime;
14920
15742
  config;
14921
- constructor(config2, runtime) {
15743
+ github;
15744
+ memory;
15745
+ constructor(config2, runtime, github = new AsterGitHub(), memory) {
14922
15746
  this.config = config2;
14923
15747
  this.runtime = runtime;
15748
+ this.github = github;
15749
+ this.memory = memory;
15750
+ }
15751
+ githubProxy;
15752
+ /** Where cells reach the repository-scoped GitHub proxy, when it is running. */
15753
+ githubProxyUrl;
15754
+ /**
15755
+ * Starts the repository-scoped GitHub proxy when configured
15756
+ * (AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN=host:port, AUTOPILOT_ASTER_GITHUB_PROXY_URL as cells see it).
15757
+ */
15758
+ async startGitHubProxy(env2 = process.env) {
15759
+ const listen = env2.AUTOPILOT_ASTER_GITHUB_PROXY_LISTEN;
15760
+ const publicUrl = env2.AUTOPILOT_ASTER_GITHUB_PROXY_URL;
15761
+ const token = env2.GITHUB_TOKEN;
15762
+ if (!listen || !publicUrl || !token || this.githubProxy) return;
15763
+ const separator = listen.lastIndexOf(":");
15764
+ const proxy = new AsterGitHubProxy({ token, resolveGrant: (grant) => this.getStore().resolveGitHubGrant(grant) });
15765
+ await proxy.listen(Number(listen.slice(separator + 1)), listen.slice(0, separator));
15766
+ this.githubProxy = proxy;
15767
+ this.githubProxyUrl = publicUrl.replace(/\/+$/, "");
15768
+ }
15769
+ push;
15770
+ getPush() {
15771
+ try {
15772
+ return this.push ??= new AsterPush();
15773
+ } catch {
15774
+ return void 0;
15775
+ }
15776
+ }
15777
+ /** "Your task finished" (or failed) on the user's devices; cancelled turns stay silent. */
15778
+ notifyTurnEnd(store, conversationId, runId, status, reason, userText, after) {
15779
+ if (status !== "completed" && status !== "failed") return;
15780
+ const push = this.getPush();
15781
+ if (!push?.configured) return;
15782
+ const reply = status === "completed" ? collectAssistantText(store, conversationId, runId, after).trim() : "";
15783
+ const title = userText.trim().split("\n")[0] || "Aster";
15784
+ const body = status === "completed" ? reply ? `\u2705 ${reply}` : "\u2705 Done" : `\u26A0\uFE0F Couldn\u2019t finish${reason && reason !== "error" ? `: ${reason}` : ""}`;
15785
+ void push.notify({ title, body, conversationId });
15786
+ }
15787
+ getMemory() {
15788
+ if (process.env.AUTOPILOT_ASTER_MEMORY === "off") return void 0;
15789
+ try {
15790
+ return this.memory ??= new AsterMemory();
15791
+ } catch {
15792
+ return void 0;
15793
+ }
14924
15794
  }
14925
15795
  async handle(req, res) {
14926
15796
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -14942,7 +15812,7 @@ var init_aster_api = __esm({
14942
15812
  service: "autopilot",
14943
15813
  apiVersion: "v1",
14944
15814
  serviceVersion: getAppVersion(),
14945
- capabilities: ["workspaces", "conversations", "turns", "event-replay", "cancellation", "human-approvals", "hotcell-per-conversation", "pause-resume", "destroy"]
15815
+ capabilities: ["workspaces", "conversations", "turns", "event-replay", "cancellation", "human-approvals", "hotcell-per-conversation", "pause-resume", "destroy", "autonomous-cells", "tool-output", "checkpoints", "model-switch", "artifacts", "memory", "push", ...this.github.configured ? ["github"] : [], ...config2.modelCatalog === "openrouter" ? ["model-catalog"] : []]
14946
15816
  });
14947
15817
  }
14948
15818
  if (path === `${API_PREFIX}/workspaces` && method === "GET") {
@@ -14953,27 +15823,86 @@ var init_aster_api = __esm({
14953
15823
  }
14954
15824
  if (path === `${API_PREFIX}/models` && method === "GET") {
14955
15825
  if (!this.hasScope(principal, "models:read")) return sendError(res, 403, "insufficient_scope", "Credential lacks model/read scope");
14956
- return json(res, 200, { models: config2.models });
15826
+ const models = await selectableModels(config2);
15827
+ if (models.length === 0) return sendError(res, 503, "model_catalog_unavailable", "The model catalog is unavailable; try again shortly");
15828
+ return json(res, 200, { models: models.map((model) => model.id), catalog: models });
14957
15829
  }
14958
15830
  if (path === `${API_PREFIX}/conversations` && method === "POST") {
14959
15831
  if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
14960
15832
  const body = await readJsonBody(req);
14961
- if (Object.keys(body).some((key) => key !== "workspaceId" && key !== "model")) {
14962
- return sendError(res, 400, "unsupported_field", "Conversation creation accepts only workspaceId and model; paths are server-configured");
15833
+ if (Object.keys(body).some((key) => !["workspaceId", "model", "kind", "repository"].includes(key))) {
15834
+ return sendError(res, 400, "unsupported_field", "Conversation creation accepts workspaceId, model, kind, and repository; paths are server-configured");
15835
+ }
15836
+ if (body.kind !== void 0 && body.kind !== "chat" && body.kind !== "code") {
15837
+ return sendError(res, 400, "invalid_kind", 'kind must be "chat" or "code"');
15838
+ }
15839
+ if (body.kind === "code" && (typeof body.repository !== "string" || !REPOSITORY_FULL_NAME_RE.test(body.repository))) {
15840
+ return sendError(res, 400, "repository_required", "Code conversations need a repository as owner/name");
15841
+ }
15842
+ if (body.kind !== "code" && body.repository !== void 0) {
15843
+ return sendError(res, 400, "unsupported_field", "repository is only accepted for code conversations");
14963
15844
  }
14964
15845
  if (typeof body.workspaceId !== "string" || !principal.workspaceIds.includes(body.workspaceId)) {
14965
15846
  return sendError(res, 403, "workspace_forbidden", "Workspace ID is not in this credential's scope");
14966
15847
  }
14967
15848
  const workspace = findAsterWorkspace(config2, body.workspaceId);
14968
15849
  if (!workspace) return sendError(res, 404, "workspace_not_found", "Workspace is not configured");
14969
- if (typeof body.model !== "string" || !config2.models.includes(body.model)) {
15850
+ if (typeof body.model !== "string" || !await isSelectableModel(config2, body.model)) {
14970
15851
  return sendError(res, 400, "model_not_allowed", "Select a model from the configured model list");
14971
15852
  }
14972
15853
  const idempotencyKey = req.headers["idempotency-key"];
14973
15854
  if (typeof idempotencyKey !== "string" || !CREATE_IDEMPOTENCY_KEY_RE.test(idempotencyKey)) {
14974
15855
  return sendError(res, 400, "idempotency_key_required", "Conversation creation requires an Idempotency-Key header of 1-128 safe characters");
14975
15856
  }
14976
- return await this.createConversation(res, store, principal, workspace, body.model, idempotencyKey);
15857
+ const kind = body.kind;
15858
+ return await this.createConversation(res, store, principal, workspace, body.model, idempotencyKey, kind, body.repository);
15859
+ }
15860
+ if (path === `${API_PREFIX}/devices` && method === "POST") {
15861
+ if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
15862
+ const body = await readJsonBody(req);
15863
+ const environment = body.environment === "development" ? "development" : "production";
15864
+ const push = this.getPush();
15865
+ if (!push || typeof body.token !== "string" || !push.register(body.token, environment)) {
15866
+ return sendError(res, 400, "invalid_device_token", "A hex APNs device token is required");
15867
+ }
15868
+ return json(res, 200, { registered: true, notifications: push.configured });
15869
+ }
15870
+ const deviceMatch = path.match(/^\/api\/v1\/devices\/([0-9a-fA-F]{64,200})$/);
15871
+ if (deviceMatch && method === "DELETE") {
15872
+ if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
15873
+ this.getPush()?.unregister(deviceMatch[1]);
15874
+ return json(res, 200, { unregistered: true });
15875
+ }
15876
+ if (path === `${API_PREFIX}/memory` && method === "GET") {
15877
+ if (!this.hasScope(principal, "conversations:read")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/read scope");
15878
+ const memory = this.getMemory();
15879
+ return json(res, 200, { enabled: Boolean(memory?.enabled), items: memory ? memory.list() : [] });
15880
+ }
15881
+ const memoryMatch = path.match(/^\/api\/v1\/memory\/([^/]+)$/);
15882
+ if (memoryMatch && method === "DELETE") {
15883
+ if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
15884
+ const memory = this.getMemory();
15885
+ if (!memory || !memory.delete(memoryMatch[1])) return sendError(res, 404, "memory_not_found", "Memory item not found");
15886
+ return json(res, 200, { deleted: memoryMatch[1] });
15887
+ }
15888
+ if (path === `${API_PREFIX}/github/repositories` && (method === "GET" || method === "POST")) {
15889
+ if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
15890
+ try {
15891
+ if (method === "GET") {
15892
+ return json(res, 200, { repositories: await this.github.listRepositories(url.searchParams.get("q") ?? "") });
15893
+ }
15894
+ const body = await readJsonBody(req);
15895
+ if (typeof body.name !== "string") return sendError(res, 400, "invalid_repository_name", "name is required");
15896
+ const repository = await this.github.createRepository({
15897
+ name: body.name.trim(),
15898
+ private: body.private !== false,
15899
+ ...typeof body.description === "string" ? { description: body.description } : {}
15900
+ });
15901
+ return json(res, 201, { repository });
15902
+ } catch (error) {
15903
+ if (error instanceof AsterGitHubError) return sendError(res, error.status, error.code, error.message);
15904
+ throw error;
15905
+ }
14977
15906
  }
14978
15907
  const eventsMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)\/events$/);
14979
15908
  if (eventsMatch && method === "GET") {
@@ -14990,7 +15919,7 @@ var init_aster_api = __esm({
14990
15919
  const conversation = await this.authorizedConversation(store, principal, turnMatch[1]);
14991
15920
  if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
14992
15921
  const body = await readJsonBody(req);
14993
- if (Object.keys(body).some((key) => key !== "text" && key !== "clientTurnId")) return sendError(res, 400, "unsupported_field", "Turns accept text and an optional clientTurnId; workspace and model are fixed by the conversation");
15922
+ if (Object.keys(body).some((key) => key !== "text" && key !== "clientTurnId" && key !== "model")) return sendError(res, 400, "unsupported_field", "Turns accept text, an optional clientTurnId, and an optional model; the workspace is fixed by the conversation");
14994
15923
  const hasClientTurnId = Object.hasOwn(body, "clientTurnId");
14995
15924
  const clientTurnId = hasClientTurnId && typeof body.clientTurnId === "string" ? body.clientTurnId : void 0;
14996
15925
  if (hasClientTurnId && (!clientTurnId || !CLIENT_TURN_ID_RE.test(clientTurnId))) return sendError(res, 400, "invalid_client_turn_id", "clientTurnId must be 1-128 ASCII letters, digits, '.', '_', ':', or '-' and start with a letter or digit");
@@ -14998,7 +15927,18 @@ var init_aster_api = __esm({
14998
15927
  const text = requestText.trim();
14999
15928
  if (!text || text.length > MAX_USER_TURN_CHARS) return sendError(res, 400, "invalid_turn", `Turn text must be 1-${MAX_USER_TURN_CHARS} characters`);
15000
15929
  if (containsLikelyProviderSecret(text)) return sendError(res, 400, "secret_input_rejected", "Provider credentials must not be sent in conversation text");
15001
- return await this.appendTurn(res, store, conversation, clientTurnId, requestText, text);
15930
+ let target = conversation;
15931
+ if (Object.hasOwn(body, "model")) {
15932
+ if (typeof body.model !== "string" || !await isSelectableModel(config2, body.model)) {
15933
+ return sendError(res, 400, "model_not_allowed", "Select a model from the model catalog");
15934
+ }
15935
+ if (body.model !== conversation.model) {
15936
+ const switched = await this.switchModel(res, store, conversation, body.model);
15937
+ if (!switched) return;
15938
+ target = switched;
15939
+ }
15940
+ }
15941
+ return await this.appendTurn(res, store, target, clientTurnId, requestText, text);
15002
15942
  }
15003
15943
  const cancelMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)\/cancel$/);
15004
15944
  if (cancelMatch && method === "POST") {
@@ -15014,6 +15954,61 @@ var init_aster_api = __esm({
15014
15954
  if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
15015
15955
  return lifecycleMatch[2] === "pause" ? this.pauseConversation(res, store, conversation) : this.resumeConversation(res, store, conversation);
15016
15956
  }
15957
+ const artifactsMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)\/artifacts(\/file)?$/);
15958
+ if (artifactsMatch && method === "GET") {
15959
+ if (!this.hasScope(principal, "conversations:read")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/read scope");
15960
+ const conversation = await this.authorizedConversation(store, principal, artifactsMatch[1]);
15961
+ if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
15962
+ const cell = this.readyCell(res, store, conversation.id);
15963
+ if (!cell) return;
15964
+ if (!this.runtime.listArtifacts || !this.runtime.readArtifact) return sendError(res, 503, "artifacts_unavailable", "Artifacts are not configured");
15965
+ if (!artifactsMatch[2]) return json(res, 200, { artifacts: await this.runtime.listArtifacts(cell) });
15966
+ try {
15967
+ const artifact = await this.runtime.readArtifact(cell, url.searchParams.get("path") ?? "");
15968
+ res.writeHead(200, {
15969
+ "content-type": artifact.mimeType,
15970
+ "content-length": artifact.data.length,
15971
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name)}`,
15972
+ "cache-control": "no-store",
15973
+ "x-content-type-options": "nosniff"
15974
+ });
15975
+ res.end(artifact.data);
15976
+ return;
15977
+ } catch (error) {
15978
+ const code = error instanceof Error ? error.message : "";
15979
+ if (code === "invalid_artifact_path") return sendError(res, 400, code, "Artifact path is invalid");
15980
+ if (code === "artifact_too_large") return sendError(res, 413, code, "Artifact is larger than 50 MB");
15981
+ return sendError(res, 404, "artifact_not_found", "Artifact not found");
15982
+ }
15983
+ }
15984
+ const checkpointsMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)\/checkpoints$/);
15985
+ if (checkpointsMatch && method === "GET") {
15986
+ if (!this.hasScope(principal, "conversations:read")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/read scope");
15987
+ const conversation = await this.authorizedConversation(store, principal, checkpointsMatch[1]);
15988
+ if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
15989
+ const cell = this.readyCell(res, store, conversation.id);
15990
+ if (!cell) return;
15991
+ if (!this.runtime.listCheckpoints) return sendError(res, 503, "checkpoints_unavailable", "Checkpoints are not configured");
15992
+ return json(res, 200, { checkpoints: await this.runtime.listCheckpoints(cell) });
15993
+ }
15994
+ const restoreMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)\/checkpoints\/([0-9a-f]{7,40})\/restore$/);
15995
+ if (restoreMatch && method === "POST") {
15996
+ if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
15997
+ const conversation = await this.authorizedConversation(store, principal, restoreMatch[1]);
15998
+ if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
15999
+ if (conversation.activeRunId) return sendError(res, 409, "turn_active", "Wait for the current turn to finish or cancel it before restoring");
16000
+ const cell = this.readyCell(res, store, conversation.id);
16001
+ if (!cell) return;
16002
+ if (!this.runtime.restoreCheckpoint) return sendError(res, 503, "checkpoints_unavailable", "Checkpoints are not configured");
16003
+ try {
16004
+ const checkpoint = await this.runtime.restoreCheckpoint(cell, conversation.id, restoreMatch[2]);
16005
+ store.appendEvent(conversation.id, null, "checkpoint.restored", { restoredFrom: restoreMatch[2], ...checkpoint });
16006
+ return json(res, 200, { checkpoint });
16007
+ } catch (error) {
16008
+ if (error instanceof Error && error.message === "checkpoint_not_found") return sendError(res, 404, "checkpoint_not_found", "Checkpoint not found");
16009
+ return sendError(res, 500, "checkpoint_restore_failed", "The workspace could not be restored");
16010
+ }
16011
+ }
15017
16012
  const conversationMatch = path.match(/^\/api\/v1\/conversations\/([^/]+)$/);
15018
16013
  if (conversationMatch && method === "DELETE") {
15019
16014
  if (!this.hasScope(principal, "conversations:write")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/write scope");
@@ -15025,7 +16020,7 @@ var init_aster_api = __esm({
15025
16020
  if (!this.hasScope(principal, "conversations:read")) return sendError(res, 403, "insufficient_scope", "Credential lacks conversation/read scope");
15026
16021
  const conversation = await this.authorizedConversation(store, principal, conversationMatch[1]);
15027
16022
  if (!conversation) return sendError(res, 404, "conversation_not_found", "Conversation not found");
15028
- return json(res, 200, publicConversation(conversation));
16023
+ return json(res, 200, publicConversation(conversation, store.getConversationProject(conversation.id)));
15029
16024
  }
15030
16025
  const approvalMatch = path.match(/^\/api\/v1\/approvals\/([^/]+)$/);
15031
16026
  if (approvalMatch && method === "GET") {
@@ -15064,6 +16059,12 @@ var init_aster_api = __esm({
15064
16059
  }
15065
16060
  this.store?.close();
15066
16061
  this.store = void 0;
16062
+ this.memory?.close();
16063
+ this.memory = void 0;
16064
+ this.githubProxy?.close();
16065
+ this.githubProxy = void 0;
16066
+ this.push?.close();
16067
+ this.push = void 0;
15067
16068
  }
15068
16069
  getStore() {
15069
16070
  return this.store ??= new AsterStore();
@@ -15115,17 +16116,60 @@ var init_aster_api = __esm({
15115
16116
  } else if (run.status === "needs_input") {
15116
16117
  runs.transition(run.id, "cancelled", "approval_expired_on_restart");
15117
16118
  store.updateConversationStatus(conversation.id, run.id, "interrupted", { reason: "approval_expired_on_restart" });
16119
+ } else {
16120
+ const mapping = store.getCellMapping(conversation.id);
16121
+ if (mapping?.cellId && this.runtime.resumeTurn) {
16122
+ this.runtime.resumeTurn(this.cellTurn(store, conversation, run.id, mapping.cellId, mapping.eventCursor, run.task ?? ""));
16123
+ } else {
16124
+ runs.transition(run.id, "failed", "server_restarted");
16125
+ store.updateConversationStatus(conversation.id, run.id, "interrupted", { reason: "server_restart_recovery" });
16126
+ }
15118
16127
  }
15119
16128
  }
15120
16129
  } finally {
15121
16130
  runs.close();
15122
16131
  }
15123
- })();
15124
- return this.recovery;
16132
+ })();
16133
+ return this.recovery;
16134
+ }
16135
+ /** A turn handle for re-attaching to a cell turn after a restart (no prompt is sent). */
16136
+ cellTurn(store, conversation, runId, cellId, cursor, userText) {
16137
+ const publishEvent = (type, data) => {
16138
+ store.appendEvent(conversation.id, runId, type, data);
16139
+ };
16140
+ return {
16141
+ runId,
16142
+ conversation,
16143
+ cellId,
16144
+ cellEventCursor: cursor,
16145
+ userText,
16146
+ sessionFile: {},
16147
+ messages: [],
16148
+ executor: new ToolExecutor([]),
16149
+ allowedTools: /* @__PURE__ */ new Set(),
16150
+ maxToolIterations: null,
16151
+ maxRuntimeMs: null,
16152
+ askPermission: async () => "deny",
16153
+ publishEvent,
16154
+ onCellCursor: (next) => store.advanceCellEventCursor(conversation.id, next),
16155
+ finish: (status, reason) => {
16156
+ const runs = new RunStore();
16157
+ try {
16158
+ const run = runs.getRun(runId);
16159
+ if (run && ["queued", "running", "waiting", "needs_input"].includes(run.status)) runs.transition(runId, status, reason ?? status);
16160
+ } catch {
16161
+ } finally {
16162
+ runs.close();
16163
+ }
16164
+ const updated = store.updateConversationStatus(conversation.id, runId, status, reason ? { reason } : {});
16165
+ if (updated) store.appendEvent(conversation.id, runId, status, reason ? { reason } : {});
16166
+ if (updated) this.notifyTurnEnd(store, conversation.id, runId, status, reason, userText, 0);
16167
+ }
16168
+ };
15125
16169
  }
15126
- async createConversation(res, store, principal, workspace, model, idempotencyKey) {
15127
- const candidateId = randomUUID6();
15128
- const candidateSessionId = randomUUID6();
16170
+ async createConversation(res, store, principal, workspace, model, idempotencyKey, kind, repository) {
16171
+ const candidateId = randomUUID7();
16172
+ const candidateSessionId = randomUUID7();
15129
16173
  const reservation = store.reserveCellConversation({
15130
16174
  conversationId: candidateId,
15131
16175
  credentialId: principal.credentialId,
@@ -15140,12 +16184,47 @@ var init_aster_api = __esm({
15140
16184
  if (mapping.status === "destroyed") return sendError(res, 410, "conversation_destroyed", "Conversation was already destroyed");
15141
16185
  if (mapping.status === "ready") {
15142
16186
  const existing = store.getConversation(mapping.conversationId);
15143
- if (existing) return json(res, 200, publicConversation(existing));
16187
+ if (existing) return json(res, 200, publicConversation(existing, store.getConversationProject(existing.id)));
15144
16188
  }
15145
16189
  if (!this.runtime.provisionConversation) {
15146
16190
  store.setCellStatus(mapping.conversationId, "failed");
15147
16191
  return sendError(res, 503, "hotcell_unavailable", "Hotcell conversation runtime is not configured");
15148
16192
  }
16193
+ let project;
16194
+ if (kind) {
16195
+ let stored = store.getConversationProject(mapping.conversationId);
16196
+ if (!stored) {
16197
+ if (kind === "code") {
16198
+ let repo;
16199
+ try {
16200
+ repo = await this.github.getRepository(repository);
16201
+ } catch (error) {
16202
+ if (error instanceof AsterGitHubError) return sendError(res, error.status, error.code, error.message);
16203
+ throw error;
16204
+ }
16205
+ store.setConversationProject(mapping.conversationId, {
16206
+ kind,
16207
+ repository: repo.fullName,
16208
+ branch: `aster/${mapping.conversationId.slice(0, 8)}`
16209
+ });
16210
+ stored = { kind, repository: repo.fullName, branch: `aster/${mapping.conversationId.slice(0, 8)}` };
16211
+ project = { kind, repository: repo.fullName, baseBranch: repo.defaultBranch, branch: stored.branch };
16212
+ } else {
16213
+ store.setConversationProject(mapping.conversationId, { kind, repository: null, branch: null });
16214
+ stored = { kind, repository: null, branch: null };
16215
+ }
16216
+ }
16217
+ if (!project) {
16218
+ project = stored.kind === "code" ? { kind: "code", repository: stored.repository, baseBranch: (await this.github.getRepository(stored.repository).catch(() => void 0))?.defaultBranch ?? "main", branch: stored.branch } : { kind: "chat" };
16219
+ }
16220
+ if (project.kind === "code" && project.repository && this.githubProxyUrl) {
16221
+ project.github = {
16222
+ token: store.getOrCreateGitHubGrant(mapping.conversationId, project.repository),
16223
+ gitUrl: `${this.githubProxyUrl}/git`,
16224
+ apiUrl: `${this.githubProxyUrl}/api`
16225
+ };
16226
+ }
16227
+ }
15149
16228
  try {
15150
16229
  const allowCreate = !mapping.createAttempted;
15151
16230
  if (allowCreate) store.markCellCreateAttempted(mapping.conversationId);
@@ -15157,7 +16236,8 @@ var init_aster_api = __esm({
15157
16236
  workspaceRoot: workspace.rootPath,
15158
16237
  model,
15159
16238
  allowCreate,
15160
- onCellCreated: (cellId) => store.setCellId(mapping.conversationId, cellId)
16239
+ onCellCreated: (cellId) => store.setCellId(mapping.conversationId, cellId),
16240
+ ...project ? { project } : {}
15161
16241
  });
15162
16242
  store.setCellId(mapping.conversationId, provisioned.cellId);
15163
16243
  const conversation = store.getConversation(mapping.conversationId) ?? store.createConversation({
@@ -15181,7 +16261,7 @@ var init_aster_api = __esm({
15181
16261
  await saveSession(sessionFile);
15182
16262
  store.setCellStatus(mapping.conversationId, "ready");
15183
16263
  store.appendEvent(mapping.conversationId, null, "conversation.created", { conversationId: mapping.conversationId, workspaceId: workspace.id, model });
15184
- json(res, reservation.kind === "created" ? 201 : 200, publicConversation(conversation));
16264
+ json(res, reservation.kind === "created" ? 201 : 200, publicConversation(conversation, store.getConversationProject(conversation.id)));
15185
16265
  } catch {
15186
16266
  const current = store.getCellMapping(mapping.conversationId);
15187
16267
  if (current?.cellId && this.runtime.destroyCell) {
@@ -15219,7 +16299,7 @@ var init_aster_api = __esm({
15219
16299
  sendError(res, 409, "conversation_cell_unavailable", "Conversation cell is not available");
15220
16300
  return;
15221
16301
  }
15222
- const runId = randomUUID6();
16302
+ const runId = randomUUID7();
15223
16303
  if (!idempotent && !store.beginTurn(conversation.id, runId)) {
15224
16304
  sendError(res, 409, "conversation_busy", "A turn is already active for this conversation");
15225
16305
  return;
@@ -15296,6 +16376,7 @@ var init_aster_api = __esm({
15296
16376
  const publishEvent = (type, data) => {
15297
16377
  store.appendEvent(conversation.id, runId, type, data);
15298
16378
  };
16379
+ const turnStartSequence = store.getConversation(conversation.id)?.lastEventSequence ?? 0;
15299
16380
  const active = {
15300
16381
  runId,
15301
16382
  conversation,
@@ -15314,8 +16395,20 @@ var init_aster_api = __esm({
15314
16395
  finish: (status, reason) => {
15315
16396
  const updated = store.updateConversationStatus(conversation.id, runId, status, reason ? { reason } : {});
15316
16397
  if (updated) store.appendEvent(conversation.id, runId, status, reason ? { reason } : {});
16398
+ if (updated) this.notifyTurnEnd(store, conversation.id, runId, status, reason, text, turnStartSequence);
15317
16399
  }
15318
16400
  };
16401
+ const memory = this.getMemory();
16402
+ if (memory) {
16403
+ active.memory = { note: memory.brief(conversation.id, text), snapshot: memory.snapshot() };
16404
+ const firstSequence = store.getConversation(conversation.id)?.lastEventSequence ?? 0;
16405
+ active.beforeFinish = async () => {
16406
+ const reply = collectAssistantText(store, conversation.id, runId, firstSequence);
16407
+ const project = store.getConversationProject(conversation.id);
16408
+ const changes = await memory.remember({ conversationId: conversation.id, project, userText: text, assistantText: reply });
16409
+ if (changes.length) publishEvent("memory.updated", { items: changes });
16410
+ };
16411
+ }
15319
16412
  this.runtime.startTurn(active);
15320
16413
  json(res, 202, acceptedResponse);
15321
16414
  } catch {
@@ -15466,6 +16559,7 @@ var init_aster_api = __esm({
15466
16559
  if (mapping.status === "destroyed") return json(res, 200, { conversationId: conversation.id, status: "destroyed" });
15467
16560
  if (!this.runtime.destroyCell) return sendError(res, 503, "hotcell_destroy_unavailable", "Hotcell cleanup is not configured");
15468
16561
  store.setCellStatus(conversation.id, "destroying");
16562
+ store.revokeGitHubGrant(conversation.id);
15469
16563
  if (conversation.activeRunId) {
15470
16564
  for (const approval of store.cancelPendingApprovals(conversation.id)) {
15471
16565
  const waiter = this.waiters.get(approval.id);
@@ -15491,6 +16585,50 @@ var init_aster_api = __esm({
15491
16585
  sendError(res, 503, "cleanup_pending", "Cell cleanup is pending; retry DELETE to revoke credentials and remove the cell");
15492
16586
  }
15493
16587
  }
16588
+ /** The conversation's cell id when it can take control requests; otherwise sends the error. */
16589
+ readyCell(res, store, conversationId) {
16590
+ const mapping = store.getCellMapping(conversationId);
16591
+ if (mapping?.status === "paused") {
16592
+ sendError(res, 409, "conversation_paused", "Conversation is paused; resume it first");
16593
+ return void 0;
16594
+ }
16595
+ if (!mapping?.cellId || mapping.status !== "ready") {
16596
+ sendError(res, 409, "conversation_cell_unavailable", "Conversation cell is not available");
16597
+ return void 0;
16598
+ }
16599
+ return mapping.cellId;
16600
+ }
16601
+ /** Switch the cell session first, then the record, so a failed switch leaves both on the old model. */
16602
+ async switchModel(res, store, conversation, model) {
16603
+ if (conversation.activeRunId) {
16604
+ sendError(res, 409, "conversation_busy", "A turn is already active for this conversation");
16605
+ return void 0;
16606
+ }
16607
+ const cell = this.readyCell(res, store, conversation.id);
16608
+ if (!cell) return void 0;
16609
+ if (!this.runtime.setModel) {
16610
+ sendError(res, 503, "model_switch_unavailable", "Model switching is not configured");
16611
+ return void 0;
16612
+ }
16613
+ try {
16614
+ await this.runtime.setModel(cell, model);
16615
+ } catch {
16616
+ sendError(res, 503, "model_switch_failed", "The conversation's model could not be switched");
16617
+ return void 0;
16618
+ }
16619
+ if (!store.setConversationModel(conversation.id, model)) {
16620
+ sendError(res, 409, "conversation_busy", "A turn is already active for this conversation");
16621
+ return void 0;
16622
+ }
16623
+ try {
16624
+ const sessionPath = resolve8(sessionsDir(), conversation.sessionId + ".json");
16625
+ const sessionFile = await loadSession(sessionPath);
16626
+ sessionFile.model = model;
16627
+ await saveSession(sessionFile);
16628
+ } catch {
16629
+ }
16630
+ return store.getConversation(conversation.id);
16631
+ }
15494
16632
  async cancelConversation(res, store, conversation) {
15495
16633
  const runId = conversation.activeRunId;
15496
16634
  if (!runId) {
@@ -15611,7 +16749,7 @@ data: ${JSON.stringify(event.data)}
15611
16749
  // src/server/aster-cell-runtime.ts
15612
16750
  import { execFile as execFile5 } from "child_process";
15613
16751
  import { promisify as promisify4 } from "util";
15614
- import { randomUUID as randomUUID7 } from "crypto";
16752
+ import { randomUUID as randomUUID8 } from "crypto";
15615
16753
  async function defaultArchiveWorkspace(workspaceRoot) {
15616
16754
  const { stdout } = await execFileAsync3("git", ["-C", workspaceRoot, "archive", "--format=tar", "HEAD"], {
15617
16755
  encoding: "buffer",
@@ -15619,17 +16757,73 @@ async function defaultArchiveWorkspace(workspaceRoot) {
15619
16757
  });
15620
16758
  return Buffer.from(stdout).toString("base64");
15621
16759
  }
16760
+ function projectNote(project) {
16761
+ const artifacts = "Save anything meant for the user to look at (documents, slides, PDFs, spreadsheets, web pages, images, reports) in /workspace/artifacts/. Files there appear in the user's Aster app (an iPhone), where they can preview, download, and share them. Unless the user asks for an editable format, deliver visual documents (slides, reports, one-pagers) as PDF: write HTML/CSS (one page per slide, e.g. @page { size: 1280px 720px; margin: 0 }) and print it with `chromium --headless --no-sandbox --disable-gpu --no-pdf-header-footer --print-to-pdf=/workspace/artifacts/NAME.pdf FILE.html`. For editable files use python-pptx, python-docx, or openpyxl; matplotlib is available for charts. Keep drafts and source files outside artifacts/ so only finished deliverables appear there.";
16762
+ if (project.kind === "chat") {
16763
+ return `[Aster chat. /workspace is this conversation's private Linux sandbox with internet access. ${artifacts}]`;
16764
+ }
16765
+ const { repository, branch, baseBranch } = project;
16766
+ return `[Aster code chat. GitHub repository ${repository} is cloned at /workspace on branch ${branch} (from ${baseBranch}). Commit your work and push with \`git push -u origin HEAD\`; credentials are preconfigured, so never print or write tokens. Only this repository is reachable from here. To open a pull request: curl -s -X POST "\${ASTER_GITHUB_API_URL:-$GITHUB_BASE_URL}/repos/${repository}/pulls" -H "Authorization: Bearer \${ASTER_GITHUB_TOKEN:-$GITHUB_API_KEY}" -H "Accept: application/vnd.github+json" -d '{"title":"\u2026","head":"${branch}","base":"${baseBranch}","body":"\u2026"}'. ${artifacts} The artifacts folder is not committed to the repository.]`;
16767
+ }
16768
+ function isSafeArtifactPath(path) {
16769
+ if (!path || path.length > 500 || path.startsWith("/") || /[\x00-\x1f\x7f]/.test(path)) return false;
16770
+ return path.split("/").every((part) => part !== "" && part !== "." && part !== ".." && !part.startsWith("."));
16771
+ }
16772
+ function shellQuote2(value) {
16773
+ return `'${value.replace(/'/g, `'\\''`)}'`;
16774
+ }
16775
+ function artifactMimeType(path) {
16776
+ const extension = path.split(".").pop()?.toLowerCase() ?? "";
16777
+ return ARTIFACT_MIME_TYPES[extension] ?? "application/octet-stream";
16778
+ }
16779
+ function checkpointMessage(userText) {
16780
+ const firstLine2 = redactLikelySecrets(userText).split("\n").find((line) => line.trim())?.trim() ?? "Turn";
16781
+ const subject = firstLine2.length > 72 ? firstLine2.slice(0, 71) + "\u2026" : firstLine2;
16782
+ return `${subject}
16783
+ `;
16784
+ }
16785
+ function summarizeToolInput(toolName, args) {
16786
+ const record = args && typeof args === "object" ? args : {};
16787
+ const preferred = toolName === "bash" ? record.command : record.path ?? record.pattern ?? record.url ?? record.query;
16788
+ const text = typeof preferred === "string" ? preferred : JSON.stringify(args ?? {});
16789
+ return boundedToolText(text, MAX_TOOL_INPUT_CHARS);
16790
+ }
16791
+ function boundedToolText(text, limit) {
16792
+ const redacted = redactLikelySecrets(text);
16793
+ if (redacted.length <= limit) return redacted;
16794
+ const half = Math.floor((limit - 40) / 2);
16795
+ return `${redacted.slice(0, half)}
16796
+ \u2026 [${redacted.length - 2 * half} characters omitted] \u2026
16797
+ ${redacted.slice(-half)}`;
16798
+ }
15622
16799
  function sleep4(ms) {
15623
16800
  return new Promise((resolve15) => setTimeout(resolve15, ms));
15624
16801
  }
15625
- var execFileAsync3, CELL_WORKSPACE, CELL_STATE_DIR, CELL_CONTROL_DIR, BRIDGE_MARKER, CONTROL_RUNNER, BRIDGE_RUNNER, HEALTH_TIMEOUT_MS, EVENT_POLL_MS, AsterHotcellRuntime;
16802
+ var execFileAsync3, CELL_WORKSPACE, CELL_STATE_DIR, CHECKPOINT_MESSAGE_FILE, INSTALLED_RUNTIME_PACKAGE, NOTES_FILE, MEMORY_FILE, MEMORY_EXTRACTION_TIMEOUT_MS, LIVENESS_CHECK_MS, ARTIFACTS_DIR, MAX_ARTIFACTS, MAX_ARTIFACT_BYTES, BRANCH_RE, REPOSITORY_RE, GIT, COMMIT_SHA_RE, MAX_TOOL_OUTPUT_CHARS, MAX_TOOL_INPUT_CHARS, MAX_CHECKPOINTS, CELL_CONTROL_DIR, BRIDGE_MARKER, CONTROL_RUNNER, BRIDGE_RUNNER, HEALTH_TIMEOUT_MS, EVENT_POLL_MS, AsterHotcellRuntime, ARTIFACT_MIME_TYPES;
15626
16803
  var init_aster_cell_runtime = __esm({
15627
16804
  "src/server/aster-cell-runtime.ts"() {
15628
16805
  "use strict";
15629
16806
  init_version();
16807
+ init_aster_tools();
15630
16808
  execFileAsync3 = promisify4(execFile5);
15631
16809
  CELL_WORKSPACE = "/workspace";
15632
16810
  CELL_STATE_DIR = "/workspace/.aster";
16811
+ CHECKPOINT_MESSAGE_FILE = `${CELL_STATE_DIR}/checkpoint-message`;
16812
+ INSTALLED_RUNTIME_PACKAGE = "/opt/autopilot/node_modules/autopilot-ai/package.json";
16813
+ NOTES_FILE = `${CELL_STATE_DIR}/pending-notes`;
16814
+ MEMORY_FILE = `${CELL_STATE_DIR}/memory.md`;
16815
+ MEMORY_EXTRACTION_TIMEOUT_MS = 15e3;
16816
+ LIVENESS_CHECK_MS = 2e4;
16817
+ ARTIFACTS_DIR = `${CELL_WORKSPACE}/artifacts`;
16818
+ MAX_ARTIFACTS = 500;
16819
+ MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
16820
+ BRANCH_RE = /^[A-Za-z0-9._/-]{1,200}$/;
16821
+ REPOSITORY_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
16822
+ GIT = `git -C ${CELL_WORKSPACE} -c user.name=Autopilot -c user.email=autopilot@aster.invalid -c commit.gpgsign=false`;
16823
+ COMMIT_SHA_RE = /^[0-9a-f]{7,40}$/;
16824
+ MAX_TOOL_OUTPUT_CHARS = 16e3;
16825
+ MAX_TOOL_INPUT_CHARS = 2e3;
16826
+ MAX_CHECKPOINTS = 100;
15633
16827
  CELL_CONTROL_DIR = `${CELL_STATE_DIR}/control`;
15634
16828
  BRIDGE_MARKER = "startAsterCellBridge";
15635
16829
  CONTROL_RUNNER = `node --input-type=module -e 'import { executeAsterCellControl } from "/opt/autopilot/node_modules/autopilot-ai/dist/sdk/index.js";const r = await executeAsterCellControl(process.argv[1]);process.stdout.write(JSON.stringify(r) + "\\n");'`;
@@ -15643,6 +16837,14 @@ var init_aster_cell_runtime = __esm({
15643
16837
  this.archiveWorkspace = options.archiveWorkspace ?? defaultArchiveWorkspace;
15644
16838
  this.pollIntervalMs = options.pollIntervalMs ?? EVENT_POLL_MS;
15645
16839
  this.healthTimeoutMs = options.healthTimeoutMs ?? HEALTH_TIMEOUT_MS;
16840
+ this.idlePauseMs = options.idlePauseMs ?? 0;
16841
+ if (this.idlePauseMs > 0) {
16842
+ const timer3 = setInterval(() => {
16843
+ void this.pauseIdleCells(this.idlePauseMs).catch(() => {
16844
+ });
16845
+ }, options.idleSweepIntervalMs ?? 6e4);
16846
+ timer3.unref();
16847
+ }
15646
16848
  }
15647
16849
  provider;
15648
16850
  packageSpec;
@@ -15650,22 +16852,63 @@ var init_aster_cell_runtime = __esm({
15650
16852
  pollIntervalMs;
15651
16853
  healthTimeoutMs;
15652
16854
  activeRuns = /* @__PURE__ */ new Map();
16855
+ idlePauseMs;
16856
+ /**
16857
+ * Pause running cells that have no active turn and no API activity within `idleMs`,
16858
+ * least recently used first, stopping after `limit` pauses. Returns the paused ids.
16859
+ */
16860
+ async pauseIdleCells(idleMs, limit = Infinity) {
16861
+ const busy = new Set([...this.activeRuns.values()].map((run) => run.cellId));
16862
+ const now2 = Date.now();
16863
+ const candidates = (await this.provider.listConversationCells()).filter((cell) => cell.status === "running" && !busy.has(cell.id) && now2 - Date.parse(cell.lastActivityAt) >= idleMs).sort((a, b) => Date.parse(a.lastActivityAt) - Date.parse(b.lastActivityAt));
16864
+ const paused = [];
16865
+ for (const cell of candidates) {
16866
+ if (paused.length >= limit) break;
16867
+ try {
16868
+ await this.provider.pauseConversationCell(cell.id);
16869
+ paused.push(cell.id);
16870
+ } catch {
16871
+ }
16872
+ }
16873
+ return paused;
16874
+ }
15653
16875
  async provisionConversation(input) {
15654
16876
  let cell = await this.provider.findConversationCell(input.conversationId);
15655
16877
  if (!cell) {
15656
16878
  if (!input.allowCreate) throw new Error("hotcell_create_uncertain");
15657
- cell = await this.provider.createConversationCell({
16879
+ const create = () => this.provider.createConversationCell({
15658
16880
  conversationId: input.conversationId,
15659
16881
  workspaceId: input.workspaceId,
15660
- model: input.model
16882
+ model: input.model,
16883
+ ...input.project ? { kind: input.project.kind } : {},
16884
+ ...input.project?.github ? {
16885
+ githubViaProxy: true,
16886
+ env: {
16887
+ ASTER_GITHUB_TOKEN: input.project.github.token,
16888
+ ASTER_GITHUB_GIT_URL: input.project.github.gitUrl,
16889
+ ASTER_GITHUB_API_URL: input.project.github.apiUrl
16890
+ }
16891
+ } : {}
15661
16892
  });
16893
+ try {
16894
+ cell = await create();
16895
+ } catch (error) {
16896
+ if ((await this.pauseIdleCells(6e4, 1).catch(() => [])).length === 0) throw error;
16897
+ cell = await create();
16898
+ }
15662
16899
  }
15663
16900
  input.onCellCreated(cell.getInfo().id);
15664
- await this.seedWorkspace(cell, input.workspaceRoot);
16901
+ if (input.project?.kind === "code") {
16902
+ await this.cloneRepository(cell, input.project);
16903
+ } else if (input.project?.kind !== "chat") {
16904
+ await this.seedWorkspace(cell, input.workspaceRoot);
16905
+ }
16906
+ await this.ensureRepository(cell, input.project?.kind === "code");
16907
+ if (input.project) await this.addNote(cell, projectNote(input.project));
15665
16908
  await this.installRuntime(cell);
15666
16909
  await this.ensureBridge(cell);
15667
16910
  await this.control(cell, "POST", "/rpc", {
15668
- id: randomUUID7(),
16911
+ id: randomUUID8(),
15669
16912
  type: "new_session",
15670
16913
  sessionId: input.sessionId,
15671
16914
  config: { model: input.model, cwd: CELL_WORKSPACE }
@@ -15681,19 +16924,76 @@ var init_aster_cell_runtime = __esm({
15681
16924
  async resumeCell(cellId) {
15682
16925
  await this.provider.resumeConversationCell(cellId);
15683
16926
  }
16927
+ /** Switch the cell session's model between turns. The bridge persists it for session restore. */
16928
+ async setModel(cellId, model) {
16929
+ await this.ensureReady(cellId);
16930
+ await this.controlById(cellId, "POST", "/rpc", { id: randomUUID8(), type: "set_model", modelId: model });
16931
+ }
16932
+ /** Reinstall the runtime if the cell's image lost it and restart the bridge if it isn't running. */
16933
+ async ensureReady(cellId) {
16934
+ const cell = await this.provider.getCell(cellId);
16935
+ await this.installRuntime(cell);
16936
+ await this.ensureBridge(cell);
16937
+ }
16938
+ /** Newest-first git history of the cell workspace; one commit per completed turn. */
16939
+ async listCheckpoints(cellId) {
16940
+ const cell = await this.provider.getCell(cellId);
16941
+ if (!await this.ensureRepository(cell)) return [];
16942
+ const result2 = await cell.exec(`${GIT} log -n ${MAX_CHECKPOINTS} --format=%H%x1f%cI%x1f%s`);
16943
+ if (result2.exitCode !== 0) return [];
16944
+ return result2.stdout.split("\n").filter(Boolean).map((line) => {
16945
+ const [id = "", createdAt = "", message2 = ""] = line.split("");
16946
+ return { id, createdAt, message: message2 };
16947
+ });
16948
+ }
16949
+ /**
16950
+ * Make the workspace match a checkpoint, recorded as a new commit so the restore
16951
+ * itself can be undone. Ignored files (node_modules, .aster state) are untouched.
16952
+ */
16953
+ async restoreCheckpoint(cellId, conversationId, checkpointId) {
16954
+ if (!COMMIT_SHA_RE.test(checkpointId)) throw new Error("invalid_checkpoint");
16955
+ const cell = await this.provider.getCell(cellId);
16956
+ if (!await this.ensureRepository(cell)) throw new Error("checkpoints_unavailable");
16957
+ const exists = await cell.exec(`${GIT} cat-file -e ${checkpointId}^{commit}`);
16958
+ if (exists.exitCode !== 0) throw new Error("checkpoint_not_found");
16959
+ const short = checkpointId.slice(0, 7);
16960
+ await cell.writeFile(CHECKPOINT_MESSAGE_FILE, `Restore checkpoint ${short}
16961
+ `);
16962
+ const restore = await cell.exec(
16963
+ `${GIT} restore --source=${checkpointId} --staged --worktree -- :/ && ${GIT} clean -fdq && ${GIT} commit -q --allow-empty -F ${CHECKPOINT_MESSAGE_FILE}`
16964
+ );
16965
+ if (restore.exitCode !== 0) throw new Error("checkpoint_restore_failed");
16966
+ void conversationId;
16967
+ await this.addNote(
16968
+ cell,
16969
+ `[The user restored the workspace to checkpoint ${short}. Files changed after that point were reverted; re-read files before relying on earlier observations.]`
16970
+ );
16971
+ const [head] = await this.listCheckpoints(cellId);
16972
+ if (!head) throw new Error("checkpoint_restore_failed");
16973
+ return head;
16974
+ }
15684
16975
  startTurn(turn) {
15685
16976
  void this.runTurn(turn).catch(() => {
15686
16977
  turn.finish("failed", "cell_runtime_error");
15687
16978
  });
15688
16979
  }
16980
+ /**
16981
+ * Re-attach to a turn that was running when the server restarted. The cell kept working
16982
+ * and journaled its events, so this only resumes reading them from the saved cursor.
16983
+ */
16984
+ resumeTurn(turn) {
16985
+ void this.runTurn(turn, { resume: true }).catch(() => {
16986
+ turn.finish("failed", "cell_runtime_error");
16987
+ });
16988
+ }
15689
16989
  cancelRun(runId) {
15690
16990
  const active = this.activeRuns.get(runId);
15691
16991
  if (!active) return;
15692
16992
  active.stop();
15693
- void this.controlById(active.cellId, "POST", "/rpc", { id: randomUUID7(), type: "abort" }).catch(() => {
16993
+ void this.controlById(active.cellId, "POST", "/rpc", { id: randomUUID8(), type: "abort" }).catch(() => {
15694
16994
  });
15695
16995
  }
15696
- async runTurn(turn) {
16996
+ async runTurn(turn, options = {}) {
15697
16997
  const { cellId, runId } = turn;
15698
16998
  let cursor = turn.cellEventCursor;
15699
16999
  let stopped = false;
@@ -15708,12 +17008,32 @@ var init_aster_cell_runtime = __esm({
15708
17008
  this.activeRuns.delete(runId);
15709
17009
  turn.finish(status, reason);
15710
17010
  };
17011
+ let outcome;
17012
+ let artifactsBefore = [];
15711
17013
  try {
15712
- await this.controlById(cellId, "POST", "/rpc", { id: runId, type: "prompt", message: turn.userText });
15713
- } catch {
15714
- finish("failed", "cell_prompt_rejected");
15715
- return;
17014
+ if (options.resume) throw new Error("resume");
17015
+ await this.ensureReady(cellId);
17016
+ const cell = await this.provider.getCell(cellId);
17017
+ if (turn.memory) {
17018
+ await cell.mkdir(CELL_STATE_DIR, { parents: true });
17019
+ await cell.writeFile(MEMORY_FILE, turn.memory.snapshot, { mode: "0600" });
17020
+ }
17021
+ const note = [await this.readNotes(cell), turn.memory?.note ?? ""].filter(Boolean).join("\n\n");
17022
+ const message2 = note ? `${note}
17023
+
17024
+ ${turn.userText}` : turn.userText;
17025
+ artifactsBefore = await this.listArtifacts(cellId).catch(() => []);
17026
+ await this.controlById(cellId, "POST", "/rpc", { id: runId, type: "prompt", message: message2 });
17027
+ if (note) await this.clearNotes(cell);
17028
+ } catch (error) {
17029
+ if (!(error instanceof Error && error.message === "resume")) {
17030
+ finish("failed", "cell_prompt_rejected");
17031
+ return;
17032
+ }
17033
+ artifactsBefore = await this.listArtifacts(cellId).catch(() => []);
15716
17034
  }
17035
+ let quietPolls = 0;
17036
+ const quietLimit = Math.max(5, Math.ceil(LIVENESS_CHECK_MS / this.pollIntervalMs));
15717
17037
  while (!stopped) {
15718
17038
  await sleep4(this.pollIntervalMs);
15719
17039
  if (stopped) break;
@@ -15724,6 +17044,18 @@ var init_aster_cell_runtime = __esm({
15724
17044
  } catch {
15725
17045
  continue;
15726
17046
  }
17047
+ if (events.length === 0) {
17048
+ if (++quietPolls >= quietLimit) {
17049
+ quietPolls = 0;
17050
+ const status = await this.controlById(cellId, "GET", "/status").catch(() => void 0);
17051
+ if (status && !status.activeRunId) {
17052
+ outcome = { status: "failed", reason: "cell_turn_lost" };
17053
+ break;
17054
+ }
17055
+ }
17056
+ continue;
17057
+ }
17058
+ quietPolls = 0;
15727
17059
  for (const entry of events) {
15728
17060
  cursor = Math.max(cursor, entry.cursor);
15729
17061
  turn.onCellCursor?.(cursor);
@@ -15736,35 +17068,72 @@ var init_aster_cell_runtime = __esm({
15736
17068
  if (typeof event.text === "string") turn.publishEvent("assistant.reasoning", { text: event.text });
15737
17069
  break;
15738
17070
  case "tool.start":
15739
- turn.publishEvent("tool.activity", { tool: event.tool, activity: "started" });
17071
+ turn.publishEvent("tool.activity", {
17072
+ tool: event.toolName,
17073
+ toolCallId: event.toolCallId,
17074
+ activity: "started",
17075
+ input: summarizeToolInput(event.toolName, event.args)
17076
+ });
17077
+ break;
17078
+ case "tool.result":
17079
+ turn.publishEvent("tool.activity", {
17080
+ tool: event.toolName,
17081
+ toolCallId: event.toolCallId,
17082
+ activity: event.isError === true ? "failed" : "completed",
17083
+ output: boundedToolText(typeof event.result === "string" ? event.result : "", MAX_TOOL_OUTPUT_CHARS)
17084
+ });
15740
17085
  break;
15741
17086
  case "permission.request": {
15742
- const approved = await turn.askPermission({
15743
- tool: { name: event.tool },
17087
+ const decision = await turn.askPermission({
17088
+ tool: { name: event.toolName },
15744
17089
  args: event.args ?? {}
15745
17090
  });
15746
17091
  await this.controlById(cellId, "POST", "/rpc", {
15747
- id: randomUUID7(),
17092
+ id: randomUUID8(),
15748
17093
  type: "resolve_permission",
15749
- permissionId: event.id,
15750
- approved
17094
+ requestId: event.requestId,
17095
+ decision
15751
17096
  }).catch(() => {
15752
17097
  });
15753
- turn.publishEvent("tool.activity", { tool: event.tool, activity: approved ? "approved" : "rejected" });
17098
+ turn.publishEvent("tool.activity", { tool: event.toolName, activity: decision === "allow" ? "approved" : "rejected" });
15754
17099
  break;
15755
17100
  }
15756
17101
  case "usage":
15757
17102
  turn.publishEvent("usage", { usage: event.usage });
15758
17103
  break;
15759
17104
  case "session.end":
15760
- finish(event.reason === "aborted" ? "cancelled" : event.reason === "complete" ? "completed" : "failed", String(event.reason ?? "cell_session_ended"));
17105
+ outcome = {
17106
+ status: event.reason === "aborted" ? "cancelled" : event.reason === "complete" ? "completed" : "failed",
17107
+ // Surface the agent's actual error (redacted, bounded) instead of a bare "error".
17108
+ reason: event.reason === "error" && typeof event.error === "string" && event.error.trim() ? boundedToolText(event.error.trim(), 500) : String(event.reason ?? "cell_session_ended")
17109
+ };
17110
+ stopped = true;
15761
17111
  break;
15762
17112
  default:
15763
17113
  break;
15764
17114
  }
17115
+ if (outcome) break;
15765
17116
  }
15766
17117
  }
15767
- if (!finished) finish("cancelled", "cell_turn_stopped");
17118
+ try {
17119
+ const before = new Map(artifactsBefore.map((artifact) => [artifact.path, artifact]));
17120
+ for (const artifact of await this.listArtifacts(cellId)) {
17121
+ const previous = before.get(artifact.path);
17122
+ if (!previous || previous.size !== artifact.size || previous.modifiedAt !== artifact.modifiedAt) {
17123
+ turn.publishEvent("artifact.updated", { ...artifact });
17124
+ }
17125
+ }
17126
+ } catch {
17127
+ }
17128
+ await this.checkpoint(cellId, turn.userText).then((checkpoint) => {
17129
+ if (checkpoint) turn.publishEvent("checkpoint.created", { ...checkpoint });
17130
+ }).catch(() => {
17131
+ });
17132
+ if (outcome?.status === "completed" && turn.beforeFinish) {
17133
+ await Promise.race([turn.beforeFinish().catch(() => {
17134
+ }), sleep4(MEMORY_EXTRACTION_TIMEOUT_MS)]);
17135
+ }
17136
+ finish(outcome?.status ?? "cancelled", outcome?.reason ?? "cell_turn_stopped");
15768
17137
  }
15769
17138
  async seedWorkspace(cell, workspaceRoot) {
15770
17139
  const seeded = await cell.exec(`test -f ${CELL_STATE_DIR}/.seeded`);
@@ -15776,15 +17145,112 @@ var init_aster_cell_runtime = __esm({
15776
17145
  if (extract.exitCode !== 0) throw new Error("workspace_seed_failed");
15777
17146
  await cell.exec(`rm -f ${CELL_STATE_DIR}/workspace.tar.b64 && touch ${CELL_STATE_DIR}/.seeded`);
15778
17147
  }
17148
+ /**
17149
+ * Make /workspace a git repository with an initial commit (idempotent), excluding the
17150
+ * bridge's private .aster state. Returns false when the cell image has no git, in
17151
+ * which case checkpoints are skipped rather than failing the conversation.
17152
+ */
17153
+ async ensureRepository(cell, excludeArtifacts = false) {
17154
+ const result2 = await cell.exec(
17155
+ `command -v git >/dev/null || exit 3; if [ ! -d ${CELL_WORKSPACE}/.git ]; then git init -q ${CELL_WORKSPACE} || exit 1; fi; grep -qx '.aster/' ${CELL_WORKSPACE}/.git/info/exclude 2>/dev/null || printf '.aster/\\n' >> ${CELL_WORKSPACE}/.git/info/exclude; ` + // A code cell's artifacts are for the user, not the repository.
17156
+ (excludeArtifacts ? `grep -qx 'artifacts/' ${CELL_WORKSPACE}/.git/info/exclude || printf 'artifacts/\\n' >> ${CELL_WORKSPACE}/.git/info/exclude; ` : "") + `${GIT} rev-parse -q --verify HEAD >/dev/null || { ${GIT} add -A && ${GIT} commit -q --allow-empty -m 'Workspace created'; }`
17157
+ );
17158
+ return result2.exitCode === 0;
17159
+ }
17160
+ /** Commit all workspace changes; returns the new checkpoint, or undefined when nothing changed. */
17161
+ async checkpoint(cellId, userText) {
17162
+ const cell = await this.provider.getCell(cellId);
17163
+ if (!await this.ensureRepository(cell)) return void 0;
17164
+ await cell.writeFile(CHECKPOINT_MESSAGE_FILE, checkpointMessage(userText), { mode: "0600" });
17165
+ const commit = await cell.exec(
17166
+ `${GIT} add -A && if ${GIT} diff --cached --quiet; then exit 4; fi && ${GIT} commit -q -F ${CHECKPOINT_MESSAGE_FILE}`
17167
+ );
17168
+ if (commit.exitCode !== 0) return void 0;
17169
+ const [head] = await this.listCheckpoints(cellId);
17170
+ return head;
17171
+ }
17172
+ /**
17173
+ * Install the Autopilot runtime unless the cell already has the expected version. This
17174
+ * checks the installed package itself rather than a marker on the persistent volume:
17175
+ * /opt lives in the cell's root filesystem, which a container-driver pause discards,
17176
+ * while an image with the runtime baked in skips the install entirely.
17177
+ */
17178
+ /**
17179
+ * Clone a GitHub repository into /workspace on a new working branch, through Hotcell's
17180
+ * credential gateway: the remote points at the gateway and git authenticates with the
17181
+ * cell's own egress token, so the real GitHub token never enters the cell. Idempotent.
17182
+ */
17183
+ async cloneRepository(cell, project) {
17184
+ const seeded = await cell.exec(`test -f ${CELL_STATE_DIR}/.seeded`);
17185
+ if (seeded.exitCode === 0) return;
17186
+ const { repository, baseBranch, branch } = project;
17187
+ if (!repository || !REPOSITORY_RE.test(repository) || !baseBranch || !BRANCH_RE.test(baseBranch) || !branch || !BRANCH_RE.test(branch)) {
17188
+ throw new Error("invalid_project");
17189
+ }
17190
+ await cell.mkdir(CELL_STATE_DIR, { parents: true });
17191
+ const clone = await cell.exec(
17192
+ `set -e; cd ${CELL_WORKSPACE}; if [ -n "$ASTER_GITHUB_GIT_URL" ]; then remote="$ASTER_GITHUB_GIT_URL/${repository}.git"; else remote="\${GITHUB_BASE_URL%/github}/github-git/${repository}.git"; fi; git init -q; git remote add origin "$remote" 2>/dev/null || git remote set-url origin "$remote"; git config credential.helper '!f() { echo username=x-access-token; echo "password=\${ASTER_GITHUB_TOKEN:-$GITHUB_API_KEY}"; }; f'; git config user.name "Aster Autopilot"; git config user.email "autopilot@aster.invalid"; if git ls-remote --exit-code --heads origin '${baseBranch}' >/dev/null 2>&1; then git fetch -q origin '${baseBranch}'; git checkout -q -b '${branch}' FETCH_HEAD; else git checkout -q -b '${branch}'; fi; touch ${CELL_STATE_DIR}/.seeded`
17193
+ );
17194
+ if (clone.exitCode !== 0) throw new Error("repository_clone_failed");
17195
+ }
17196
+ /** Files under /workspace/artifacts, the folder the agent saves user-facing deliverables in. */
17197
+ async listArtifacts(cellId) {
17198
+ const cell = await this.provider.getCell(cellId);
17199
+ const result2 = await cell.exec(
17200
+ `[ -d ${ARTIFACTS_DIR} ] || exit 0; cd ${ARTIFACTS_DIR} && find . -type f ! -path '*/.*' -printf '%P\\t%s\\t%T@\\n' | head -n ${MAX_ARTIFACTS}`
17201
+ );
17202
+ if (result2.exitCode !== 0) return [];
17203
+ return result2.stdout.split("\n").filter(Boolean).flatMap((line) => {
17204
+ const [path = "", size = "", modified = ""] = line.split(" ");
17205
+ if (!isSafeArtifactPath(path)) return [];
17206
+ return [{
17207
+ path,
17208
+ name: path.split("/").pop() ?? path,
17209
+ size: Number(size) || 0,
17210
+ modifiedAt: new Date(Math.round(Number(modified) * 1e3) || Date.now()).toISOString(),
17211
+ mimeType: artifactMimeType(path)
17212
+ }];
17213
+ }).sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
17214
+ }
17215
+ /** The bytes of one artifact, at most 50 MB. */
17216
+ async readArtifact(cellId, path) {
17217
+ if (!isSafeArtifactPath(path)) throw new Error("invalid_artifact_path");
17218
+ const cell = await this.provider.getCell(cellId);
17219
+ const file = shellQuote2(`${ARTIFACTS_DIR}/${path}`);
17220
+ const stat10 = await cell.exec(`[ -f ${file} ] && [ ! -L ${file} ] && stat -c %s -- ${file}`);
17221
+ if (stat10.exitCode !== 0) throw new Error("artifact_not_found");
17222
+ if (Number(stat10.stdout.trim()) > MAX_ARTIFACT_BYTES) throw new Error("artifact_too_large");
17223
+ const encoded = await cell.exec(`base64 -w0 -- ${file}`);
17224
+ if (encoded.exitCode !== 0) throw new Error("artifact_not_found");
17225
+ return { data: Buffer.from(encoded.stdout.trim(), "base64"), mimeType: artifactMimeType(path), name: path.split("/").pop() ?? path };
17226
+ }
17227
+ /** Notes are prepended to the agent's next prompt once; kept in the cell so they survive restarts. */
17228
+ async addNote(cell, text) {
17229
+ const existing = await this.readNotes(cell);
17230
+ await cell.mkdir(CELL_STATE_DIR, { parents: true });
17231
+ await cell.writeFile(NOTES_FILE, existing ? `${existing}
17232
+
17233
+ ${text}` : text, { mode: "0600" });
17234
+ }
17235
+ async readNotes(cell) {
17236
+ try {
17237
+ return (await cell.readFile(NOTES_FILE)).trim();
17238
+ } catch {
17239
+ return "";
17240
+ }
17241
+ }
17242
+ async clearNotes(cell) {
17243
+ await cell.exec(`rm -f ${NOTES_FILE}`);
17244
+ }
15779
17245
  async installRuntime(cell) {
15780
- const marker = `${CELL_STATE_DIR}/.runtime-version`;
17246
+ const expectedVersion = /@(\d[^@/]*)$/.exec(this.packageSpec)?.[1];
15781
17247
  try {
15782
- if (await cell.readFile(marker) === this.packageSpec) return;
17248
+ const installed = JSON.parse(await cell.readFile(INSTALLED_RUNTIME_PACKAGE));
17249
+ if (!expectedVersion || installed.version === expectedVersion) return;
15783
17250
  } catch {
15784
17251
  }
15785
17252
  const install = await cell.exec(`mkdir -p /opt/autopilot && npm install --prefix /opt/autopilot --no-audit --no-fund ${this.packageSpec}`);
15786
17253
  if (install.exitCode !== 0) throw new Error("cell_runtime_install_failed");
15787
- await cell.writeFile(marker, this.packageSpec);
15788
17254
  }
15789
17255
  async ensureBridge(cell) {
15790
17256
  const processes = await cell.listProcesses().catch(() => []);
@@ -15797,7 +17263,7 @@ var init_aster_cell_runtime = __esm({
15797
17263
  for (; ; ) {
15798
17264
  try {
15799
17265
  const health = await this.control(cell, "GET", "/health");
15800
- if (health.ok === true) return;
17266
+ if (health.ok === true && (!health.sessionId || health.initialized === true)) return;
15801
17267
  } catch {
15802
17268
  }
15803
17269
  if (Date.now() > deadline) throw new Error("cell_bridge_unhealthy");
@@ -15805,7 +17271,7 @@ var init_aster_cell_runtime = __esm({
15805
17271
  }
15806
17272
  }
15807
17273
  async control(cell, method, path, body) {
15808
- const requestId = randomUUID7();
17274
+ const requestId = randomUUID8();
15809
17275
  await cell.mkdir(CELL_CONTROL_DIR, { parents: true });
15810
17276
  await cell.writeFile(`${CELL_CONTROL_DIR}/${requestId}.json`, JSON.stringify({ method, path, body }), { mode: "0600" });
15811
17277
  const result2 = await cell.exec(`${CONTROL_RUNNER} ${requestId}`);
@@ -15819,6 +17285,31 @@ var init_aster_cell_runtime = __esm({
15819
17285
  return this.control(cell, method, path, body);
15820
17286
  }
15821
17287
  };
17288
+ ARTIFACT_MIME_TYPES = {
17289
+ pdf: "application/pdf",
17290
+ html: "text/html",
17291
+ htm: "text/html",
17292
+ md: "text/markdown",
17293
+ txt: "text/plain",
17294
+ csv: "text/csv",
17295
+ json: "application/json",
17296
+ png: "image/png",
17297
+ jpg: "image/jpeg",
17298
+ jpeg: "image/jpeg",
17299
+ gif: "image/gif",
17300
+ webp: "image/webp",
17301
+ svg: "image/svg+xml",
17302
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
17303
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17304
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17305
+ key: "application/vnd.apple.keynote",
17306
+ pages: "application/vnd.apple.pages",
17307
+ numbers: "application/vnd.apple.numbers",
17308
+ zip: "application/zip",
17309
+ mp4: "video/mp4",
17310
+ mov: "video/quicktime",
17311
+ mp3: "audio/mpeg"
17312
+ };
15822
17313
  }
15823
17314
  });
15824
17315
 
@@ -15831,8 +17322,8 @@ function validateConfig(config2) {
15831
17322
  throw new HotcellProviderError("Hotcell must use HTTPS unless the control endpoint is loopback", "insecure_hotcell_endpoint");
15832
17323
  }
15833
17324
  if (!config2.apiKey.trim()) throw new HotcellProviderError("Hotcell API key is required", "hotcell_auth_required");
15834
- if (config2.driver !== "applevz" && config2.driver !== "firecracker") {
15835
- throw new HotcellProviderError("Aster requires an explicit Hotcell microVM driver", "unsafe_hotcell_driver");
17325
+ if (!HOTCELL_DRIVERS.includes(config2.driver)) {
17326
+ throw new HotcellProviderError("Aster requires an explicit Hotcell driver", "unsafe_hotcell_driver");
15836
17327
  }
15837
17328
  if (!Number.isInteger(config2.memoryMb) || config2.memoryMb < 512 || !Number.isFinite(config2.cpus) || config2.cpus < 0.5 || !Number.isInteger(config2.pidsLimit) || config2.pidsLimit < 32) {
15838
17329
  throw new HotcellProviderError("Hotcell resource limits are invalid", "invalid_hotcell_limits");
@@ -15856,11 +17347,12 @@ function numberEnv(value, fallback, min, max) {
15856
17347
  if (!Number.isFinite(parsed) || parsed < min || parsed > max) throw new HotcellProviderError("Invalid Hotcell resource configuration", "invalid_hotcell_config");
15857
17348
  return parsed;
15858
17349
  }
15859
- var HOTCELL_CONVERSATION_LABEL, HotcellProviderError, HotcellProvider;
17350
+ var HOTCELL_CONVERSATION_LABEL, HOTCELL_DRIVERS, HotcellProviderError, HotcellProvider;
15860
17351
  var init_hotcell_provider = __esm({
15861
17352
  "src/server/hotcell-provider.ts"() {
15862
17353
  "use strict";
15863
17354
  HOTCELL_CONVERSATION_LABEL = "autopilot.conversation_id";
17355
+ HOTCELL_DRIVERS = ["applevz", "firecracker", "container"];
15864
17356
  HotcellProviderError = class extends Error {
15865
17357
  constructor(message2, code) {
15866
17358
  super(message2);
@@ -15881,9 +17373,9 @@ var init_hotcell_provider = __esm({
15881
17373
  const endpoint = env2.AUTOPILOT_HOTCELL_ENDPOINT;
15882
17374
  const apiKey = env2.AUTOPILOT_HOTCELL_API_KEY;
15883
17375
  const driver = env2.AUTOPILOT_HOTCELL_DRIVER;
15884
- if (!endpoint || !apiKey || driver !== "applevz" && driver !== "firecracker") {
17376
+ if (!endpoint || !apiKey || !HOTCELL_DRIVERS.includes(driver)) {
15885
17377
  throw new HotcellProviderError(
15886
- "Aster Hotcell requires AUTOPILOT_HOTCELL_ENDPOINT, AUTOPILOT_HOTCELL_API_KEY, and an explicit AUTOPILOT_HOTCELL_DRIVER (applevz or firecracker)",
17378
+ "Aster Hotcell requires AUTOPILOT_HOTCELL_ENDPOINT, AUTOPILOT_HOTCELL_API_KEY, and an explicit AUTOPILOT_HOTCELL_DRIVER (applevz, firecracker, or container)",
15887
17379
  "hotcell_not_configured"
15888
17380
  );
15889
17381
  }
@@ -15891,6 +17383,11 @@ var init_hotcell_provider = __esm({
15891
17383
  endpoint,
15892
17384
  apiKey,
15893
17385
  driver,
17386
+ networked: env2.AUTOPILOT_HOTCELL_NETWORKED === "true",
17387
+ anyModel: env2.AUTOPILOT_HOTCELL_ANY_MODEL === "true",
17388
+ sleepAfterMs: integerEnv(env2.AUTOPILOT_HOTCELL_SLEEP_AFTER_MS, 0, 0, 7 * 24 * 60 * 60 * 1e3),
17389
+ ...env2.AUTOPILOT_HOTCELL_IMAGE ? { image: env2.AUTOPILOT_HOTCELL_IMAGE } : {},
17390
+ ...env2.AUTOPILOT_HOTCELL_CHAT_MEMORY_MB ? { chatMemoryMb: integerEnv(env2.AUTOPILOT_HOTCELL_CHAT_MEMORY_MB, 1024, 512, 65536) } : {},
15894
17391
  memoryMb: integerEnv(env2.AUTOPILOT_HOTCELL_MEMORY_MB, 4096, 512, 65536),
15895
17392
  cpus: numberEnv(env2.AUTOPILOT_HOTCELL_CPUS, 2, 0.5, 64),
15896
17393
  pidsLimit: integerEnv(env2.AUTOPILOT_HOTCELL_PIDS, 256, 32, 8192),
@@ -15915,23 +17412,28 @@ var init_hotcell_provider = __esm({
15915
17412
  const info = await this.client.info();
15916
17413
  if (!info.auth) throw new HotcellProviderError("Hotcell API authentication is disabled; refusing to provision an Aster cell", "hotcell_auth_required");
15917
17414
  if (!info.drivers.includes(this.config.driver)) {
15918
- throw new HotcellProviderError("Configured Hotcell microVM driver is unavailable", "hotcell_driver_unavailable");
17415
+ throw new HotcellProviderError("Configured Hotcell driver is unavailable", "hotcell_driver_unavailable");
15919
17416
  }
15920
17417
  if (!info.egressProviders.includes("openrouter")) {
15921
17418
  throw new HotcellProviderError("Hotcell OpenRouter credential gateway is unavailable", "hotcell_egress_unavailable");
15922
17419
  }
17420
+ if (input.kind === "code" && !input.githubViaProxy && !info.egressProviders.includes("github")) {
17421
+ throw new HotcellProviderError("Hotcell GitHub credential gateway is unavailable", "hotcell_github_unavailable");
17422
+ }
15923
17423
  const options = {
15924
17424
  driver: this.config.driver,
15925
- networked: false,
17425
+ ...this.config.image ? { image: this.config.image } : {},
17426
+ ...input.env ? { env: input.env } : {},
17427
+ networked: this.config.networked === true,
15926
17428
  persist: true,
15927
- sleepAfter: 0,
15928
- memoryMb: this.config.memoryMb,
17429
+ sleepAfter: this.config.sleepAfterMs ?? 0,
17430
+ memoryMb: input.kind === "chat" ? this.config.chatMemoryMb ?? this.config.memoryMb : this.config.memoryMb,
15929
17431
  cpus: this.config.cpus,
15930
17432
  pidsLimit: this.config.pidsLimit,
15931
17433
  egressSpendCapUsd: this.config.spendCapUsd,
15932
17434
  egress: {
15933
- providers: ["openrouter"],
15934
- models: [input.model],
17435
+ providers: input.kind === "code" && !input.githubViaProxy ? ["openrouter", "github", "github-git"] : ["openrouter"],
17436
+ ...this.config.anyModel ? {} : { models: [input.model] },
15935
17437
  spendCapUsd: this.config.spendCapUsd,
15936
17438
  ttlMs: this.config.tokenTtlMs
15937
17439
  },
@@ -15961,6 +17463,15 @@ var init_hotcell_provider = __esm({
15961
17463
  for (const token of tokens) await cell.revokeEgressToken(token.token);
15962
17464
  await cell.destroy();
15963
17465
  }
17466
+ /** Aster conversation cells with their lifecycle status and last API activity. */
17467
+ async listConversationCells() {
17468
+ return (await this.client.list()).filter((item) => item.labels?.[HOTCELL_CONVERSATION_LABEL]).map((item) => ({
17469
+ id: item.id,
17470
+ conversationId: item.labels[HOTCELL_CONVERSATION_LABEL],
17471
+ status: item.status,
17472
+ lastActivityAt: item.lastActivityAt
17473
+ }));
17474
+ }
15964
17475
  /** True suspend: on microVM drivers the daemon snapshots memory, so the Autopilot session resumes alive. */
15965
17476
  async pauseConversationCell(cellId) {
15966
17477
  if (!cellId.trim()) throw new HotcellProviderError("Missing Hotcell id", "hotcell_id_missing");
@@ -15975,13 +17486,16 @@ var init_hotcell_provider = __esm({
15975
17486
  });
15976
17487
 
15977
17488
  // src/server/routes.ts
15978
- import { randomUUID as randomUUID8 } from "crypto";
17489
+ import { randomUUID as randomUUID9 } from "crypto";
15979
17490
  import { URL as URL2 } from "url";
15980
- import { readFile as readFile20, unlink as unlink3 } from "fs/promises";
17491
+ import { readFile as readFile21, unlink as unlink3 } from "fs/promises";
15981
17492
  import { resolve as resolve9, basename as basename5 } from "path";
15982
17493
  function createHotcellAsterRuntime() {
15983
17494
  try {
15984
- return new AsterHotcellRuntime(HotcellProvider.fromEnvironment());
17495
+ const idlePauseMs = Number(process.env.AUTOPILOT_ASTER_IDLE_PAUSE_MS ?? 15 * 60 * 1e3);
17496
+ return new AsterHotcellRuntime(HotcellProvider.fromEnvironment(), {
17497
+ idlePauseMs: Number.isFinite(idlePauseMs) && idlePauseMs >= 0 ? idlePauseMs : 15 * 60 * 1e3
17498
+ });
15985
17499
  } catch (error) {
15986
17500
  if (error instanceof HotcellProviderError && error.code === "hotcell_not_configured") return void 0;
15987
17501
  throw error;
@@ -16067,7 +17581,7 @@ async function buildUserMessage2(prompt, files, cwd) {
16067
17581
  }
16068
17582
  } else {
16069
17583
  try {
16070
- const content = await readFile20(filePath, "utf8");
17584
+ const content = await readFile21(filePath, "utf8");
16071
17585
  const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
16072
17586
  fileContents.push(`
16073
17587
  --- ${relPath} ---
@@ -16146,6 +17660,12 @@ function setupRoutes(config2, options = {}) {
16146
17660
  cancelRun: () => {
16147
17661
  }
16148
17662
  });
17663
+ if (!options.asterRuntime) {
17664
+ void asterApi.startGitHubProxy().catch((error) => {
17665
+ process.stderr.write(`aster: GitHub proxy failed to start: ${error instanceof Error ? error.message : String(error)}
17666
+ `);
17667
+ });
17668
+ }
16149
17669
  const wakeScheduler = new RunWakeScheduler({
16150
17670
  onWake: async (event) => {
16151
17671
  const store = new RunStore();
@@ -16288,7 +17808,7 @@ function setupRoutes(config2, options = {}) {
16288
17808
  const executor = new ToolExecutor(tools);
16289
17809
  const { makeSessionId: makeSessionId2 } = await Promise.resolve().then(() => (init_sessions(), sessions_exports));
16290
17810
  const sessionId = makeSessionId2(task);
16291
- const runId = randomUUID8();
17811
+ const runId = randomUUID9();
16292
17812
  const worktreeManager = new RunWorktreeManager();
16293
17813
  let worktree;
16294
17814
  if (useWorktree) {
@@ -16870,10 +18390,10 @@ var server_exports = {};
16870
18390
  __export(server_exports, {
16871
18391
  startServer: () => startServer
16872
18392
  });
16873
- import { createServer } from "http";
18393
+ import { createServer as createServer2 } from "http";
16874
18394
  async function startServer(opts2) {
16875
18395
  const { handleRequest, cleanup } = setupRoutes(opts2.config, { asterRuntime: opts2.asterRuntime });
16876
- const server = createServer((req, res) => {
18396
+ const server = createServer2((req, res) => {
16877
18397
  const isAsterApi = req.url?.startsWith("/api/v1/") === true;
16878
18398
  if (!isAsterApi) {
16879
18399
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -16972,9 +18492,9 @@ __export(db_exports, {
16972
18492
  updateAccessedAt: () => updateAccessedAt,
16973
18493
  updateMemoryEmbedding: () => updateMemoryEmbedding
16974
18494
  });
16975
- import Database4 from "better-sqlite3";
16976
- import { dirname as dirname15 } from "path";
16977
- import { mkdirSync as mkdirSync7, statSync as statSync4 } from "fs";
18495
+ import Database6 from "better-sqlite3";
18496
+ import { dirname as dirname17 } from "path";
18497
+ import { mkdirSync as mkdirSync8, statSync as statSync4 } from "fs";
16978
18498
  function initSchema(db) {
16979
18499
  db.exec(`
16980
18500
  CREATE TABLE IF NOT EXISTS memories (
@@ -17058,8 +18578,8 @@ function openMemoryDb(dbPath) {
17058
18578
  if (dbInstance) {
17059
18579
  dbInstance.close();
17060
18580
  }
17061
- mkdirSync7(dirname15(dbPath), { recursive: true });
17062
- dbInstance = new Database4(dbPath);
18581
+ mkdirSync8(dirname17(dbPath), { recursive: true });
18582
+ dbInstance = new Database6(dbPath);
17063
18583
  dbInstance.pragma("journal_mode = WAL");
17064
18584
  dbInstance.pragma("foreign_keys = ON");
17065
18585
  initSchema(dbInstance);
@@ -19013,120 +20533,6 @@ var init_lsp = __esm({
19013
20533
  }
19014
20534
  });
19015
20535
 
19016
- // src/models/openrouter-catalog.ts
19017
- var openrouter_catalog_exports = {};
19018
- __export(openrouter_catalog_exports, {
19019
- DEFAULT_CACHE_TTL_MS: () => DEFAULT_CACHE_TTL_MS,
19020
- ensureOpenRouterCatalog: () => ensureOpenRouterCatalog,
19021
- fetchOpenRouterCatalog: () => fetchOpenRouterCatalog,
19022
- loadOpenRouterCatalog: () => loadOpenRouterCatalog,
19023
- mapOpenRouterModel: () => mapOpenRouterModel
19024
- });
19025
- import { readFile as readFile21, writeFile as writeFile12, mkdir as mkdir13 } from "fs/promises";
19026
- import { homedir as homedir22 } from "os";
19027
- import { join as join31, dirname as dirname16 } from "path";
19028
- function catalogCachePath() {
19029
- const xdg = process.env.XDG_CONFIG_HOME || join31(homedir22(), ".config");
19030
- return join31(xdg, "kimiflare", "openrouter-models.json");
19031
- }
19032
- function perMtok(perToken) {
19033
- const n = Number(perToken);
19034
- return Number.isFinite(n) ? n * 1e6 : 0;
19035
- }
19036
- function mapOpenRouterModel(raw) {
19037
- const supported = new Set(raw.supported_parameters ?? []);
19038
- const aa = raw.benchmarks?.artificial_analysis;
19039
- const quality = {
19040
- ...typeof aa?.coding_index === "number" ? { coding: aa.coding_index } : {},
19041
- ...typeof aa?.agentic_index === "number" ? { agentic: aa.agentic_index } : {},
19042
- ...typeof aa?.intelligence_index === "number" ? { intelligence: aa.intelligence_index } : {}
19043
- };
19044
- return {
19045
- id: raw.id,
19046
- ...raw.name ? { name: raw.name } : {},
19047
- ...typeof raw.created === "number" ? { created: raw.created } : {},
19048
- ...Object.keys(quality).length > 0 ? { quality } : {},
19049
- ...raw.supported_parameters ? { parameters: [...raw.supported_parameters] } : {},
19050
- contextWindow: raw.context_length ?? 128e3,
19051
- maxOutputTokens: raw.top_provider?.max_completion_tokens ?? 4096,
19052
- pricing: {
19053
- inputPerMtok: perMtok(raw.pricing?.prompt),
19054
- ...raw.pricing?.input_cache_read ? { cachedInputPerMtok: perMtok(raw.pricing.input_cache_read) } : {},
19055
- outputPerMtok: perMtok(raw.pricing?.completion)
19056
- },
19057
- supports: {
19058
- // OpenRouter's own catalog is the authority on whether a model accepts tool
19059
- // calls; absence of "tools" here means the upstream provider doesn't support
19060
- // function calling for this model, and we must not claim otherwise.
19061
- tools: supported.has("tools"),
19062
- reasoning: supported.has("reasoning") || supported.has("include_reasoning"),
19063
- streaming: true,
19064
- vision: (raw.architecture?.input_modalities ?? []).includes("image"),
19065
- temperature: supported.has("temperature") ? true : void 0
19066
- }
19067
- };
19068
- }
19069
- async function fetchOpenRouterCatalog(fetchImpl2 = fetch) {
19070
- const res = await fetchWithNetworkRetry(fetchImpl2, openRouterUrl("models"), {
19071
- headers: { Accept: "application/json" }
19072
- });
19073
- if (!res.ok) {
19074
- throw new Error(`OpenRouter /models returned HTTP ${res.status}`);
19075
- }
19076
- const body = await res.json();
19077
- return (body.data ?? []).map(mapOpenRouterModel);
19078
- }
19079
- async function readCache3() {
19080
- try {
19081
- const raw = await readFile21(catalogCachePath(), "utf8");
19082
- const parsed = JSON.parse(raw);
19083
- return parsed.version === CACHE_VERSION ? parsed : null;
19084
- } catch {
19085
- return null;
19086
- }
19087
- }
19088
- async function writeCache3(models) {
19089
- const path = catalogCachePath();
19090
- try {
19091
- await mkdir13(dirname16(path), { recursive: true });
19092
- const cache = { version: CACHE_VERSION, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), models };
19093
- await writeFile12(path, JSON.stringify(cache, null, 2), "utf8");
19094
- } catch {
19095
- }
19096
- }
19097
- async function loadOpenRouterCatalog(opts2 = {}) {
19098
- const ttlMs = opts2.ttlMs ?? DEFAULT_CACHE_TTL_MS;
19099
- const cached = await readCache3();
19100
- const isFresh = cached && Date.now() - Date.parse(cached.fetchedAt) < ttlMs;
19101
- if (isFresh) return cached.models;
19102
- try {
19103
- const models = await fetchOpenRouterCatalog(opts2.fetchImpl ?? fetch);
19104
- await writeCache3(models);
19105
- return models;
19106
- } catch {
19107
- return cached?.models ?? [];
19108
- }
19109
- }
19110
- async function ensureOpenRouterCatalog(opts2 = {}) {
19111
- try {
19112
- const models = await loadOpenRouterCatalog(opts2);
19113
- if (models.length > 0) registerOpenRouterModels(models);
19114
- return models.length;
19115
- } catch {
19116
- return 0;
19117
- }
19118
- }
19119
- var CACHE_VERSION, DEFAULT_CACHE_TTL_MS;
19120
- var init_openrouter_catalog = __esm({
19121
- "src/models/openrouter-catalog.ts"() {
19122
- "use strict";
19123
- init_registry();
19124
- init_openrouter();
19125
- CACHE_VERSION = 4;
19126
- DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
19127
- }
19128
- });
19129
-
19130
20536
  // src/models/requesty-catalog.ts
19131
20537
  var requesty_catalog_exports = {};
19132
20538
  __export(requesty_catalog_exports, {
@@ -19139,7 +20545,7 @@ __export(requesty_catalog_exports, {
19139
20545
  });
19140
20546
  import { readFile as readFile22, writeFile as writeFile13, mkdir as mkdir14 } from "fs/promises";
19141
20547
  import { homedir as homedir23 } from "os";
19142
- import { join as join32, dirname as dirname17 } from "path";
20548
+ import { join as join32, dirname as dirname18 } from "path";
19143
20549
  function catalogCachePath2() {
19144
20550
  const xdg = process.env.XDG_CONFIG_HOME || join32(homedir23(), ".config");
19145
20551
  return join32(xdg, "kimiflare", "requesty-models.json");
@@ -19213,7 +20619,7 @@ async function readCache4() {
19213
20619
  async function writeCache4(catalog) {
19214
20620
  const path = catalogCachePath2();
19215
20621
  try {
19216
- await mkdir14(dirname17(path), { recursive: true });
20622
+ await mkdir14(dirname18(path), { recursive: true });
19217
20623
  const cache = { version: CACHE_VERSION2, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), ...catalog };
19218
20624
  await writeFile13(path, JSON.stringify(cache, null, 2), "utf8");
19219
20625
  } catch {
@@ -21261,7 +22667,7 @@ __export(app_helpers_exports, {
21261
22667
  trackRecentFile: () => trackRecentFile
21262
22668
  });
21263
22669
  import { execSync as execSync3, spawn as spawn6 } from "child_process";
21264
- import { existsSync as existsSync5, readFileSync as readFileSync5, statSync as statSync5 } from "fs";
22670
+ import { existsSync as existsSync5, readFileSync as readFileSync6, statSync as statSync5 } from "fs";
21265
22671
  import { join as join34 } from "path";
21266
22672
  import { platform as platform3 } from "os";
21267
22673
  function replaceQueuedPrompt(queue2, key, full, display) {
@@ -21375,7 +22781,7 @@ function buildFilePickerIgnoreList(cwd) {
21375
22781
  if (stats.size > MAX_GITIGNORE_SIZE) {
21376
22782
  return hardcoded;
21377
22783
  }
21378
- const content = readFileSync5(gitignorePath, "utf-8");
22784
+ const content = readFileSync6(gitignorePath, "utf-8");
21379
22785
  for (const line of content.split(/\r?\n/)) {
21380
22786
  const trimmed = line.trim();
21381
22787
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -25833,6 +27239,8 @@ function humanizeToolTitle(toolName, originalTitle, tier) {
25833
27239
  return humanizeMcpTitle(toolName, originalTitle, tier);
25834
27240
  }
25835
27241
  switch (toolName) {
27242
+ case "execute_code":
27243
+ return "Running code batch";
25836
27244
  case "read":
25837
27245
  return pick(tier, {
25838
27246
  light: `Taking a quick look at ${extractPath(originalTitle)}`,
@@ -26523,6 +27931,7 @@ function isSettled(e) {
26523
27931
  if (e.kind === "assistant") return !e.streaming;
26524
27932
  if (e.kind === "tool") return e.status !== "running" && e.status !== "queued";
26525
27933
  if (e.kind === "user") return !e.queued;
27934
+ if (e.kind === "info") return !e.pending;
26526
27935
  return true;
26527
27936
  }
26528
27937
  function firstUnsettledIndex(events) {
@@ -29447,12 +30856,12 @@ auto (default) lets the coordinator assess substantial tasks and delegate indepe
29447
30856
  setEvents((events) => [
29448
30857
  ...events,
29449
30858
  { kind: "user", key: mkKey2(), text: `/jev ${rest.join(" ")}` },
29450
- { kind: "info", key: eventKey, text: "Jev is evaluating\u2026" }
30859
+ { kind: "info", key: eventKey, text: "Jev is evaluating\u2026", pending: true }
29451
30860
  ]);
29452
30861
  try {
29453
30862
  const context = noContext ? [] : await buildJevContext(question.prompt, ctx.messagesRef.current, process.cwd());
29454
30863
  const receipt = formatJevContextReceipt(context, showContext);
29455
- setResult(eventKey, { kind: "info", key: eventKey, text: `Jev is evaluating \xB7 ${receipt}` });
30864
+ setResult(eventKey, { kind: "info", key: eventKey, text: `Jev is evaluating \xB7 ${receipt}`, pending: true });
29456
30865
  await new Promise((resolve15) => setTimeout(resolve15, 0));
29457
30866
  const answer = await askJev(apiKey, question, { context });
29458
30867
  const presentation = presentJevAnswer(question, answer);
@@ -30905,7 +32314,7 @@ __export(openrouter_oauth_exports, {
30905
32314
  startLoopbackListener: () => startLoopbackListener
30906
32315
  });
30907
32316
  import { createHash as createHash4, randomBytes as randomBytes3 } from "crypto";
30908
- import { createServer as createServer2 } from "http";
32317
+ import { createServer as createServer3 } from "http";
30909
32318
  import { hostname } from "os";
30910
32319
  function createPkce() {
30911
32320
  const verifier = randomBytes3(32).toString("base64url");
@@ -30961,7 +32370,7 @@ async function startLoopbackListener(opts2 = {}) {
30961
32370
  });
30962
32371
  code.catch(() => void 0);
30963
32372
  let settled = false;
30964
- const server = createServer2((req, res) => {
32373
+ const server = createServer3((req, res) => {
30965
32374
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
30966
32375
  if (url.pathname !== "/callback") {
30967
32376
  res.writeHead(404).end();
@@ -36285,7 +37694,7 @@ var init_run_compact = __esm({
36285
37694
 
36286
37695
  // src/commands/save.ts
36287
37696
  import { mkdir as mkdir16, writeFile as writeFile15, unlink as unlink5 } from "fs/promises";
36288
- import { dirname as dirname19 } from "path";
37697
+ import { dirname as dirname20 } from "path";
36289
37698
  async function saveCustomCommand(opts2) {
36290
37699
  const dir = opts2.source === "project" ? projectCommandsDir(opts2.cwd) : globalCommandsDir();
36291
37700
  const filepath = `${dir}/${opts2.name}.md`;
@@ -36296,7 +37705,7 @@ async function saveCustomCommand(opts2) {
36296
37705
  if (opts2.effort) data.effort = opts2.effort;
36297
37706
  const frontmatter = serializeFrontmatter(data);
36298
37707
  const content = frontmatter + opts2.template;
36299
- await mkdir16(dirname19(filepath), { recursive: true });
37708
+ await mkdir16(dirname20(filepath), { recursive: true });
36300
37709
  await writeFile15(filepath, content, "utf8");
36301
37710
  return { filepath };
36302
37711
  }