@shmulikdav/solix 1.5.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -245,6 +245,7 @@ import { fileURLToPath } from "url";
245
245
  var SOLIX_HOME = process.env.SOLIX_HOME ?? join2(homedir2(), ".solix");
246
246
  var HOOKS_DIR = join2(SOLIX_HOME, "hooks");
247
247
  var SOLIX_SKILLS_DIR = join2(SOLIX_HOME, "skills");
248
+ var SOLIX_TOKEN_FILE = join2(SOLIX_HOME, "token");
248
249
  var CLAUDE_DIR = join2(homedir2(), ".claude");
249
250
  var CLAUDE_SETTINGS = join2(CLAUDE_DIR, "settings.json");
250
251
  var CLAUDE_BACKUP = join2(CLAUDE_DIR, "settings.solix.backup.json");
@@ -601,6 +602,7 @@ import {
601
602
  writeFileSync as writeFileSync2,
602
603
  chmodSync
603
604
  } from "fs";
605
+ import { randomBytes } from "crypto";
604
606
  import { join as join4 } from "path";
605
607
  function readSettings() {
606
608
  if (!existsSync3(CLAUDE_SETTINGS)) return {};
@@ -654,6 +656,15 @@ function mergeHooks(existing, solix) {
654
656
  }
655
657
  return merged;
656
658
  }
659
+ function ensureToken() {
660
+ if (existsSync3(SOLIX_TOKEN_FILE)) return;
661
+ const token = randomBytes(24).toString("hex");
662
+ writeFileSync2(SOLIX_TOKEN_FILE, token, { mode: 384 });
663
+ try {
664
+ chmodSync(SOLIX_TOKEN_FILE, 384);
665
+ } catch {
666
+ }
667
+ }
657
668
  function installHookScripts() {
658
669
  mkdirSync(HOOKS_DIR, { recursive: true });
659
670
  const src = packagedHooksDir();
@@ -719,6 +730,7 @@ function install(opts = {}) {
719
730
  } else if (opts.force && existsSync3(CLAUDE_SETTINGS)) {
720
731
  copyFileSync(CLAUDE_SETTINGS, CLAUDE_BACKUP);
721
732
  }
733
+ ensureToken();
722
734
  installHookScripts();
723
735
  console.log(`[solix] installed hook scripts in ${HOOKS_DIR}`);
724
736
  const advisorsCopied = installAdvisorAgents();
@@ -1020,8 +1032,145 @@ async function installSkillCmd(id, projectId) {
1020
1032
  }
1021
1033
  }
1022
1034
 
1035
+ // src/schedule.ts
1036
+ var PORT6 = process.env.SOLIX_PORT ?? "4242";
1037
+ var BASE6 = `http://127.0.0.1:${PORT6}`;
1038
+ async function api4(path, init) {
1039
+ const res = await fetch(`${BASE6}${path}`, {
1040
+ ...init,
1041
+ headers: { "content-type": "application/json", ...init?.headers ?? {} }
1042
+ });
1043
+ if (!res.ok) {
1044
+ const text = await res.text().catch(() => "");
1045
+ throw new Error(`HTTP ${res.status} on ${path}: ${text}`);
1046
+ }
1047
+ return await res.json();
1048
+ }
1049
+ function unreachable(err) {
1050
+ console.error(`[solix] could not reach server at ${BASE6}: ${String(err)}`);
1051
+ console.error("[solix] is `solix start` running?");
1052
+ process.exitCode = 1;
1053
+ }
1054
+ async function listSchedulesCmd() {
1055
+ try {
1056
+ const list = await api4("/api/schedules");
1057
+ if (!list.length) {
1058
+ console.log("No schedules. Add one with `solix schedule add`.");
1059
+ return;
1060
+ }
1061
+ console.log("id every state next run prompt");
1062
+ for (const s of list) {
1063
+ const next = new Date(s.nextRunAt).toLocaleString();
1064
+ const state = s.enabled ? "on " : "off";
1065
+ console.log(
1066
+ ` ${s.id.padEnd(8)} ${s.cron.padEnd(5)} [${state}] ${next.padEnd(20)} ${s.prompt.slice(0, 40)}`
1067
+ );
1068
+ }
1069
+ } catch (err) {
1070
+ unreachable(err);
1071
+ }
1072
+ }
1073
+ async function addScheduleCmd(prompt, opts) {
1074
+ const cwd = opts.cwd ?? process.cwd();
1075
+ const cadence = opts.every ?? "1h";
1076
+ try {
1077
+ const s = await api4("/api/schedules", {
1078
+ method: "POST",
1079
+ body: JSON.stringify({ cwd, prompt, cadence, name: opts.name })
1080
+ });
1081
+ console.log(
1082
+ `[solix] scheduled ${s.id} \u2014 every ${s.cron} in ${cwd}
1083
+ next run: ${new Date(s.nextRunAt).toLocaleString()}`
1084
+ );
1085
+ } catch (err) {
1086
+ unreachable(err);
1087
+ }
1088
+ }
1089
+ async function toggle(id, enabled) {
1090
+ try {
1091
+ await api4(`/api/schedules/${encodeURIComponent(id)}/toggle`, {
1092
+ method: "POST",
1093
+ body: JSON.stringify({ enabled })
1094
+ });
1095
+ console.log(`[solix] schedule ${id} \u2192 ${enabled ? "enabled" : "disabled"}`);
1096
+ } catch (err) {
1097
+ unreachable(err);
1098
+ }
1099
+ }
1100
+ var enableScheduleCmd = (id) => toggle(id, true);
1101
+ var disableScheduleCmd = (id) => toggle(id, false);
1102
+ async function removeScheduleCmd(id) {
1103
+ try {
1104
+ await api4(`/api/schedules/${encodeURIComponent(id)}`, { method: "DELETE" });
1105
+ console.log(`[solix] removed schedule ${id}`);
1106
+ } catch (err) {
1107
+ unreachable(err);
1108
+ }
1109
+ }
1110
+
1111
+ // src/goals.ts
1112
+ var PORT7 = process.env.SOLIX_PORT ?? "4242";
1113
+ var BASE7 = `http://127.0.0.1:${PORT7}`;
1114
+ async function api5(path, init) {
1115
+ const res = await fetch(`${BASE7}${path}`, {
1116
+ ...init,
1117
+ headers: { "content-type": "application/json", ...init?.headers ?? {} }
1118
+ });
1119
+ if (!res.ok) {
1120
+ const text = await res.text().catch(() => "");
1121
+ throw new Error(`HTTP ${res.status} on ${path}: ${text}`);
1122
+ }
1123
+ return await res.json();
1124
+ }
1125
+ function unreachable2(err) {
1126
+ console.error(`[solix] could not reach server at ${BASE7}: ${String(err)}`);
1127
+ console.error("[solix] is `solix start` running?");
1128
+ process.exitCode = 1;
1129
+ }
1130
+ async function listGoalsCmd() {
1131
+ try {
1132
+ const goals = await api5("/api/goals");
1133
+ if (!goals.length) {
1134
+ console.log('No goals. Add one with `solix goal add "<name>"`.');
1135
+ return;
1136
+ }
1137
+ console.log("id color name");
1138
+ for (const g of goals) {
1139
+ console.log(` ${g.id.padEnd(8)} ${g.color.padEnd(8)} ${g.name}`);
1140
+ }
1141
+ } catch (err) {
1142
+ unreachable2(err);
1143
+ }
1144
+ }
1145
+ async function addGoalCmd(name, opts) {
1146
+ try {
1147
+ const g = await api5("/api/goals", {
1148
+ method: "POST",
1149
+ body: JSON.stringify({
1150
+ name,
1151
+ description: opts.description,
1152
+ color: opts.color
1153
+ })
1154
+ });
1155
+ console.log(`[solix] created goal ${g.id} \u2014 "${g.name}" (${g.color})`);
1156
+ } catch (err) {
1157
+ unreachable2(err);
1158
+ }
1159
+ }
1160
+ async function removeGoalCmd(id) {
1161
+ try {
1162
+ await api5(`/api/goals/${encodeURIComponent(id)}`, { method: "DELETE" });
1163
+ console.log(`[solix] removed goal ${id}`);
1164
+ } catch (err) {
1165
+ unreachable2(err);
1166
+ }
1167
+ }
1168
+
1023
1169
  // ../server/src/create.ts
1024
1170
  import { serve } from "@hono/node-server";
1171
+ import { readFileSync as readFileSync8 } from "fs";
1172
+ import { homedir as homedir11 } from "os";
1173
+ import { join as join15 } from "path";
1025
1174
 
1026
1175
  // ../server/src/broadcaster.ts
1027
1176
  var Broadcaster = class {
@@ -1197,6 +1346,14 @@ CREATE TABLE IF NOT EXISTS scheduled_tasks (
1197
1346
  last_run_at INTEGER,
1198
1347
  next_run_at INTEGER NOT NULL
1199
1348
  );
1349
+
1350
+ CREATE TABLE IF NOT EXISTS goals (
1351
+ id TEXT PRIMARY KEY,
1352
+ name TEXT NOT NULL,
1353
+ description TEXT,
1354
+ color TEXT NOT NULL,
1355
+ created_at INTEGER NOT NULL
1356
+ );
1200
1357
  `;
1201
1358
  var _db = null;
1202
1359
  function ensureColumn(db, table, column, ddl) {
@@ -1222,6 +1379,12 @@ function getDb() {
1222
1379
  ensureColumn(db, "sessions", "pr_check_status", "pr_check_status TEXT");
1223
1380
  ensureColumn(db, "advisors", "texture_pack", "texture_pack TEXT");
1224
1381
  ensureColumn(db, "missions", "error_summary", "error_summary TEXT");
1382
+ ensureColumn(db, "sessions", "cost_usd", "cost_usd REAL DEFAULT 0");
1383
+ ensureColumn(db, "sessions", "budget_usd", "budget_usd REAL");
1384
+ ensureColumn(db, "sessions", "current_goal_id", "current_goal_id TEXT");
1385
+ ensureColumn(db, "missions", "goal_id", "goal_id TEXT");
1386
+ ensureColumn(db, "scheduled_tasks", "cwd", "cwd TEXT");
1387
+ ensureColumn(db, "scheduled_tasks", "name", "name TEXT");
1225
1388
  _db = db;
1226
1389
  return db;
1227
1390
  }
@@ -1311,7 +1474,10 @@ function rowToSession(row) {
1311
1474
  agentViewId: row.agent_view_id ?? void 0,
1312
1475
  agentViewSummary: row.agent_view_summary ?? void 0,
1313
1476
  prUrl: row.pr_url ?? void 0,
1314
- prCheckStatus: row.pr_check_status ?? void 0
1477
+ prCheckStatus: row.pr_check_status ?? void 0,
1478
+ costUsd: row.cost_usd ?? 0,
1479
+ budgetUsd: row.budget_usd ?? void 0,
1480
+ currentGoalId: row.current_goal_id ?? void 0
1315
1481
  };
1316
1482
  }
1317
1483
  function nextOrbitSlot(db, projectId) {
@@ -1392,7 +1558,8 @@ function upsertSession(db, input) {
1392
1558
  agentViewId: input.agentViewId,
1393
1559
  agentViewSummary: input.agentViewSummary,
1394
1560
  prUrl: input.prUrl,
1395
- prCheckStatus: input.prCheckStatus
1561
+ prCheckStatus: input.prCheckStatus,
1562
+ costUsd: 0
1396
1563
  };
1397
1564
  }
1398
1565
  function setAgentViewFields(db, sessionId, fields) {
@@ -1456,6 +1623,27 @@ function setSessionContextUsage(db, sessionId, pct) {
1456
1623
  ).run(clamped, ts2, sessionId);
1457
1624
  return getSession(db, sessionId);
1458
1625
  }
1626
+ function setSessionCost(db, sessionId, costUsd) {
1627
+ const ts2 = now();
1628
+ db.prepare(
1629
+ `UPDATE sessions SET cost_usd = ?, updated_at = ? WHERE id = ?`
1630
+ ).run(Math.max(0, costUsd), ts2, sessionId);
1631
+ return getSession(db, sessionId);
1632
+ }
1633
+ function setSessionBudget(db, sessionId, budgetUsd) {
1634
+ const ts2 = now();
1635
+ db.prepare(
1636
+ `UPDATE sessions SET budget_usd = ?, updated_at = ? WHERE id = ?`
1637
+ ).run(budgetUsd, ts2, sessionId);
1638
+ return getSession(db, sessionId);
1639
+ }
1640
+ function setSessionGoal(db, sessionId, goalId) {
1641
+ const ts2 = now();
1642
+ db.prepare(
1643
+ `UPDATE sessions SET current_goal_id = ?, updated_at = ? WHERE id = ?`
1644
+ ).run(goalId, ts2, sessionId);
1645
+ return getSession(db, sessionId);
1646
+ }
1459
1647
  function getSession(db, sessionId) {
1460
1648
  const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId);
1461
1649
  return row ? rowToSession(row) : null;
@@ -1504,7 +1692,8 @@ function rowToMission(row) {
1504
1692
  toolCallCount: row.tool_call_count
1505
1693
  },
1506
1694
  filesTouched,
1507
- errorSummary: row.error_summary ?? void 0
1695
+ errorSummary: row.error_summary ?? void 0,
1696
+ goalId: row.goal_id ?? void 0
1508
1697
  };
1509
1698
  }
1510
1699
  function setMissionError(db, missionId, errorSummary) {
@@ -1520,14 +1709,14 @@ function shortNameFromPrompt(prompt) {
1520
1709
  (w) => w.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().replace(/^./, (c) => c.toUpperCase())
1521
1710
  ).filter(Boolean).join(" ") || "New Mission";
1522
1711
  }
1523
- function startMission(db, sessionId, prompt) {
1712
+ function startMission(db, sessionId, prompt, goalId) {
1524
1713
  const id = nanoid2();
1525
1714
  const ts2 = now();
1526
1715
  const shortName = shortNameFromPrompt(prompt);
1527
1716
  db.prepare(
1528
- `INSERT INTO missions (id, session_id, prompt, short_name, status, started_at, files_touched_json)
1529
- VALUES (?, ?, ?, ?, 'active', ?, '[]')`
1530
- ).run(id, sessionId, prompt, shortName, ts2);
1717
+ `INSERT INTO missions (id, session_id, prompt, short_name, status, started_at, files_touched_json, goal_id)
1718
+ VALUES (?, ?, ?, ?, 'active', ?, '[]', ?)`
1719
+ ).run(id, sessionId, prompt, shortName, ts2, goalId ?? null);
1531
1720
  return {
1532
1721
  id,
1533
1722
  sessionId,
@@ -1536,9 +1725,16 @@ function startMission(db, sessionId, prompt) {
1536
1725
  shortName,
1537
1726
  status: "active",
1538
1727
  metrics: { subagentCount: 0, toolCallCount: 0 },
1539
- filesTouched: []
1728
+ filesTouched: [],
1729
+ goalId
1540
1730
  };
1541
1731
  }
1732
+ function addMissionTokens(db, missionId, tokens) {
1733
+ if (tokens <= 0) return;
1734
+ db.prepare(
1735
+ `UPDATE missions SET total_tokens = COALESCE(total_tokens, 0) + ? WHERE id = ?`
1736
+ ).run(Math.round(tokens), missionId);
1737
+ }
1542
1738
  function completeMission(db, missionId, status = "completed") {
1543
1739
  const ts2 = now();
1544
1740
  const row = db.prepare("SELECT * FROM missions WHERE id = ?").get(missionId);
@@ -1980,6 +2176,138 @@ function writeToWrapperSocket(socketPath, text) {
1980
2176
  }
1981
2177
  }
1982
2178
 
2179
+ // ../server/src/state/schedules.ts
2180
+ import { nanoid as nanoid4 } from "nanoid";
2181
+ function rowToSchedule(row) {
2182
+ return {
2183
+ id: row.id,
2184
+ projectId: row.project_id,
2185
+ cwd: row.cwd ?? "",
2186
+ name: row.name ?? void 0,
2187
+ prompt: row.prompt,
2188
+ cron: row.cron,
2189
+ enabled: row.enabled !== 0,
2190
+ lastRunAt: row.last_run_at ?? void 0,
2191
+ nextRunAt: row.next_run_at
2192
+ };
2193
+ }
2194
+ function cadenceToMs(cadence) {
2195
+ const m = cadence.trim().match(/^(\d+)\s*([mhd])$/i);
2196
+ if (!m) return null;
2197
+ const n = Number(m[1]);
2198
+ if (!Number.isFinite(n) || n <= 0) return null;
2199
+ const unit = m[2].toLowerCase();
2200
+ const mult = unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5;
2201
+ return n * mult;
2202
+ }
2203
+ function nextRunFrom(fromMs, cadence) {
2204
+ const ms = cadenceToMs(cadence);
2205
+ return fromMs + (ms ?? 36e5);
2206
+ }
2207
+ function createSchedule(db, input) {
2208
+ const project = ensureProject(db, input.cwd);
2209
+ const id = nanoid4(8);
2210
+ const ts2 = now();
2211
+ const nextRun = nextRunFrom(ts2, input.cadence);
2212
+ db.prepare(
2213
+ `INSERT INTO scheduled_tasks
2214
+ (id, project_id, cwd, name, prompt, cron, enabled, last_run_at, next_run_at)
2215
+ VALUES (?, ?, ?, ?, ?, ?, 1, NULL, ?)`
2216
+ ).run(id, project.id, input.cwd, input.name ?? null, input.prompt, input.cadence, nextRun);
2217
+ return getSchedule(db, id);
2218
+ }
2219
+ function getSchedule(db, id) {
2220
+ const row = db.prepare("SELECT * FROM scheduled_tasks WHERE id = ?").get(id);
2221
+ return row ? rowToSchedule(row) : null;
2222
+ }
2223
+ function listSchedules(db) {
2224
+ const rows = db.prepare("SELECT * FROM scheduled_tasks ORDER BY next_run_at ASC").all();
2225
+ return rows.map(rowToSchedule);
2226
+ }
2227
+ function listDueSchedules(db, asOf) {
2228
+ const rows = db.prepare(
2229
+ `SELECT * FROM scheduled_tasks WHERE enabled = 1 AND next_run_at <= ?`
2230
+ ).all(asOf);
2231
+ return rows.map(rowToSchedule);
2232
+ }
2233
+ function setScheduleEnabled(db, id, enabled) {
2234
+ db.prepare("UPDATE scheduled_tasks SET enabled = ? WHERE id = ?").run(
2235
+ enabled ? 1 : 0,
2236
+ id
2237
+ );
2238
+ return getSchedule(db, id);
2239
+ }
2240
+ function markScheduleRun(db, id) {
2241
+ const sched = getSchedule(db, id);
2242
+ if (!sched) return null;
2243
+ const ts2 = now();
2244
+ const next = nextRunFrom(ts2, sched.cron);
2245
+ db.prepare(
2246
+ "UPDATE scheduled_tasks SET last_run_at = ?, next_run_at = ? WHERE id = ?"
2247
+ ).run(ts2, next, id);
2248
+ return getSchedule(db, id);
2249
+ }
2250
+ function deleteSchedule(db, id) {
2251
+ const res = db.prepare("DELETE FROM scheduled_tasks WHERE id = ?").run(id);
2252
+ return res.changes > 0;
2253
+ }
2254
+
2255
+ // ../server/src/state/goals.ts
2256
+ import { nanoid as nanoid5 } from "nanoid";
2257
+ var PALETTE = [
2258
+ "#38bdf8",
2259
+ // sky
2260
+ "#a78bfa",
2261
+ // violet
2262
+ "#34d399",
2263
+ // emerald
2264
+ "#fbbf24",
2265
+ // amber
2266
+ "#f472b6",
2267
+ // pink
2268
+ "#f87171",
2269
+ // red
2270
+ "#22d3ee",
2271
+ // cyan
2272
+ "#c084fc"
2273
+ // purple
2274
+ ];
2275
+ function rowToGoal(row) {
2276
+ return {
2277
+ id: row.id,
2278
+ name: row.name,
2279
+ description: row.description ?? void 0,
2280
+ color: row.color,
2281
+ createdAt: row.created_at
2282
+ };
2283
+ }
2284
+ function nextColor(db) {
2285
+ const count = db.prepare("SELECT COUNT(*) AS n FROM goals").get().n;
2286
+ return PALETTE[count % PALETTE.length];
2287
+ }
2288
+ function createGoal(db, input) {
2289
+ const id = nanoid5(8);
2290
+ const ts2 = now();
2291
+ const color = input.color ?? nextColor(db);
2292
+ db.prepare(
2293
+ `INSERT INTO goals (id, name, description, color, created_at)
2294
+ VALUES (?, ?, ?, ?, ?)`
2295
+ ).run(id, input.name, input.description ?? null, color, ts2);
2296
+ return { id, name: input.name, description: input.description, color, createdAt: ts2 };
2297
+ }
2298
+ function listGoals(db) {
2299
+ const rows = db.prepare("SELECT * FROM goals ORDER BY created_at ASC").all();
2300
+ return rows.map(rowToGoal);
2301
+ }
2302
+ function deleteGoal(db, id) {
2303
+ db.prepare(
2304
+ `UPDATE sessions SET current_goal_id = NULL WHERE current_goal_id = ?`
2305
+ ).run(id);
2306
+ db.prepare(`UPDATE missions SET goal_id = NULL WHERE goal_id = ?`).run(id);
2307
+ const res = db.prepare("DELETE FROM goals WHERE id = ?").run(id);
2308
+ return res.changes > 0;
2309
+ }
2310
+
1983
2311
  // ../server/src/state/context.ts
1984
2312
  var MISSIONS_FOR_HANDOFF = 3;
1985
2313
  var DEFAULT_ASKS = {
@@ -2186,7 +2514,7 @@ function recordSkillInstall(db, skillId, projectId) {
2186
2514
  }
2187
2515
 
2188
2516
  // ../server/src/state/galaxy.ts
2189
- import { nanoid as nanoid4 } from "nanoid";
2517
+ import { nanoid as nanoid6 } from "nanoid";
2190
2518
  function exportManifest(db, opts = {}) {
2191
2519
  const advisors2 = listAdvisors(db);
2192
2520
  const skills2 = listSkills(db);
@@ -2234,7 +2562,7 @@ function importManifest(db, manifest, sourceUrl) {
2234
2562
  db.prepare(
2235
2563
  `INSERT INTO galaxy_imports (id, source_url, manifest_json, imported_at)
2236
2564
  VALUES (?, ?, ?, ?)`
2237
- ).run(nanoid4(), sourceUrl ?? null, JSON.stringify(manifest), now());
2565
+ ).run(nanoid6(), sourceUrl ?? null, JSON.stringify(manifest), now());
2238
2566
  return {
2239
2567
  advisorsEnabled: enabled,
2240
2568
  advisorsDisabled: disabled,
@@ -2295,7 +2623,7 @@ function snapshotExport(db, manifest) {
2295
2623
  return rowToVersion(existing);
2296
2624
  }
2297
2625
  }
2298
- const id = nanoid4();
2626
+ const id = nanoid6();
2299
2627
  const ts2 = now();
2300
2628
  const ordinal = (last?.ordinal ?? 0) + 1;
2301
2629
  db.prepare(
@@ -2373,6 +2701,29 @@ function diffManifests(a, b) {
2373
2701
  };
2374
2702
  }
2375
2703
 
2704
+ // ../shared/src/pricing.ts
2705
+ var MODEL_PRICING = {
2706
+ opus: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
2707
+ sonnet: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
2708
+ haiku: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }
2709
+ };
2710
+ function pricingFor(model) {
2711
+ const m = (model ?? "").toLowerCase();
2712
+ if (m.includes("opus")) return MODEL_PRICING.opus;
2713
+ if (m.includes("haiku")) return MODEL_PRICING.haiku;
2714
+ return MODEL_PRICING.sonnet;
2715
+ }
2716
+ function costForUsage(model, usage) {
2717
+ if (!usage) return 0;
2718
+ const p = pricingFor(model);
2719
+ const cost = ((usage.input_tokens ?? 0) * p.input + (usage.output_tokens ?? 0) * p.output + (usage.cache_read_input_tokens ?? 0) * p.cacheRead + (usage.cache_creation_input_tokens ?? 0) * p.cacheWrite) / 1e6;
2720
+ return cost;
2721
+ }
2722
+ function totalTokens(usage) {
2723
+ if (!usage) return 0;
2724
+ return (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
2725
+ }
2726
+
2376
2727
  // ../server/src/cloud.ts
2377
2728
  var RegistryClient = class {
2378
2729
  constructor(baseUrl = process.env.SOLIX_REGISTRY_URL ?? "", apiKey = process.env.SOLIX_REGISTRY_KEY) {
@@ -2462,7 +2813,29 @@ function isAgentViewVersion(version) {
2462
2813
  }
2463
2814
  function createHttpApp(opts) {
2464
2815
  const app = new Hono();
2465
- app.use("*", cors());
2816
+ app.use(
2817
+ "*",
2818
+ cors({
2819
+ origin: [
2820
+ "http://127.0.0.1:4242",
2821
+ "http://localhost:4242",
2822
+ "http://127.0.0.1:4243",
2823
+ "http://localhost:4243"
2824
+ ]
2825
+ })
2826
+ );
2827
+ if (opts.token) {
2828
+ const expected = opts.token;
2829
+ const paths = ["/events", "/events/permission"];
2830
+ for (const p of paths) {
2831
+ app.use(p, async (c, next) => {
2832
+ if (c.req.header("x-solix-token") !== expected) {
2833
+ return c.json({ error: "unauthorized" }, 401);
2834
+ }
2835
+ await next();
2836
+ });
2837
+ }
2838
+ }
2466
2839
  const registry = new RegistryClient();
2467
2840
  app.get(
2468
2841
  "/api/health",
@@ -2486,6 +2859,18 @@ function createHttpApp(opts) {
2486
2859
  }
2487
2860
  return c.json({ ok: true });
2488
2861
  });
2862
+ app.post("/events/permission", async (c) => {
2863
+ let body = null;
2864
+ try {
2865
+ body = await c.req.json();
2866
+ } catch {
2867
+ return c.json({ decision: "allow" });
2868
+ }
2869
+ if (!body || !body.event) return c.json({ decision: "allow" });
2870
+ const result = await opts.router.requestPermission(body);
2871
+ const decision = result.timedOut ? "timeout" : result.approved ? "allow" : "deny";
2872
+ return c.json({ decision });
2873
+ });
2489
2874
  app.get("/api/projects", (c) => c.json(listProjects(opts.db)));
2490
2875
  app.get("/api/projects/:id/sessions", (c) => {
2491
2876
  const id = c.req.param("id");
@@ -2566,11 +2951,11 @@ function createHttpApp(opts) {
2566
2951
  return c.json({ ...a, agentMd: readAdvisorAgentMd(a) });
2567
2952
  });
2568
2953
  app.post("/api/advisors/:id/enable", (c) => {
2569
- const a = setAdvisorEnabled(opts.db, c.req.param("id"), true);
2954
+ const a = opts.router.setAdvisorEnabled(c.req.param("id"), true);
2570
2955
  return c.json({ ok: Boolean(a), advisor: a });
2571
2956
  });
2572
2957
  app.post("/api/advisors/:id/disable", (c) => {
2573
- const a = setAdvisorEnabled(opts.db, c.req.param("id"), false);
2958
+ const a = opts.router.setAdvisorEnabled(c.req.param("id"), false);
2574
2959
  return c.json({ ok: Boolean(a), advisor: a });
2575
2960
  });
2576
2961
  app.post("/api/advisors/:id/pin", (c) => {
@@ -2726,6 +3111,52 @@ function createHttpApp(opts) {
2726
3111
  return c.json({ ok: true });
2727
3112
  });
2728
3113
  app.get("/api/wrappers", (c) => c.json(listWrappers()));
3114
+ app.get("/api/schedules", (c) => c.json(listSchedules(opts.db)));
3115
+ app.post("/api/schedules", async (c) => {
3116
+ const body = await c.req.json().catch(() => ({}));
3117
+ if (!body.cwd || !body.prompt || !body.cadence) {
3118
+ return c.json({ error: "cwd, prompt, cadence required" }, 400);
3119
+ }
3120
+ const schedule2 = createSchedule(opts.db, {
3121
+ cwd: body.cwd,
3122
+ prompt: body.prompt,
3123
+ cadence: body.cadence,
3124
+ name: body.name
3125
+ });
3126
+ opts.router.broadcastScheduleUpsert(schedule2);
3127
+ return c.json(schedule2);
3128
+ });
3129
+ app.post("/api/schedules/:id/toggle", async (c) => {
3130
+ const body = await c.req.json().catch(() => ({}));
3131
+ const s = setScheduleEnabled(opts.db, c.req.param("id"), Boolean(body.enabled));
3132
+ if (!s) return c.json({ error: "not found" }, 404);
3133
+ opts.router.broadcastScheduleUpsert(s);
3134
+ return c.json(s);
3135
+ });
3136
+ app.delete("/api/schedules/:id", (c) => {
3137
+ const id = c.req.param("id");
3138
+ const ok = deleteSchedule(opts.db, id);
3139
+ if (ok) opts.router.broadcastScheduleRemove(id);
3140
+ return c.json({ ok });
3141
+ });
3142
+ app.get("/api/goals", (c) => c.json(listGoals(opts.db)));
3143
+ app.post("/api/goals", async (c) => {
3144
+ const body = await c.req.json().catch(() => ({}));
3145
+ if (!body.name) return c.json({ error: "name required" }, 400);
3146
+ const goal2 = createGoal(opts.db, {
3147
+ name: body.name,
3148
+ description: body.description,
3149
+ color: body.color
3150
+ });
3151
+ opts.router.broadcastGoalUpsert(goal2);
3152
+ return c.json(goal2);
3153
+ });
3154
+ app.delete("/api/goals/:id", (c) => {
3155
+ const id = c.req.param("id");
3156
+ const ok = deleteGoal(opts.db, id);
3157
+ if (ok) opts.router.broadcastGoalRemove(id);
3158
+ return c.json({ ok });
3159
+ });
2729
3160
  let preflightCache = null;
2730
3161
  app.get("/api/system/preflight", (c) => {
2731
3162
  if (preflightCache) return c.json(preflightCache);
@@ -2867,7 +3298,7 @@ import { spawn, spawnSync as spawnSync2 } from "child_process";
2867
3298
  import { existsSync as existsSync9, mkdirSync as mkdirSync4 } from "fs";
2868
3299
  import { homedir as homedir8 } from "os";
2869
3300
  import { basename as basename3, join as join12 } from "path";
2870
- import { nanoid as nanoid5 } from "nanoid";
3301
+ import { nanoid as nanoid7 } from "nanoid";
2871
3302
  function ensureWorktree(opts) {
2872
3303
  const repoRoot = (() => {
2873
3304
  const r = spawnSync2("git", ["rev-parse", "--show-toplevel"], {
@@ -2909,6 +3340,53 @@ function ensureWorktree(opts) {
2909
3340
  return { path, created: true };
2910
3341
  }
2911
3342
  var FAKE_CLAUDE = process.env.SOLIX_FAKE_CLAUDE === "1";
3343
+ function buildSpawnEnv() {
3344
+ const scrub = process.env.SOLIX_ENV_SCRUB === "1" || (process.env.SOLIX_SANDBOX_CMD ?? "").trim() !== "";
3345
+ if (!scrub) return void 0;
3346
+ const allow = [
3347
+ "PATH",
3348
+ "HOME",
3349
+ "USER",
3350
+ "LOGNAME",
3351
+ "SHELL",
3352
+ "LANG",
3353
+ "LC_ALL",
3354
+ "TERM",
3355
+ "TMPDIR",
3356
+ "TZ",
3357
+ "HTTP_PROXY",
3358
+ "HTTPS_PROXY",
3359
+ "NO_PROXY",
3360
+ "http_proxy",
3361
+ "https_proxy",
3362
+ "no_proxy",
3363
+ "SOLIX_HOME",
3364
+ "SOLIX_HOST",
3365
+ "SOLIX_PORT",
3366
+ "SOLIX_GATE_ENABLED",
3367
+ "SOLIX_GATE_POLICY",
3368
+ "SOLIX_GATE_TIMEOUT"
3369
+ ];
3370
+ const extra = (process.env.SOLIX_ENV_PASSTHROUGH ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3371
+ const keep = /* @__PURE__ */ new Set([...allow, ...extra]);
3372
+ const env = {};
3373
+ for (const k of keep) {
3374
+ if (process.env[k] !== void 0) env[k] = process.env[k];
3375
+ }
3376
+ for (const k of Object.keys(process.env)) {
3377
+ if (k.startsWith("ANTHROPIC_") || k.startsWith("CLAUDE_")) {
3378
+ env[k] = process.env[k];
3379
+ }
3380
+ }
3381
+ return env;
3382
+ }
3383
+ function sandboxWrap(file, args) {
3384
+ const cmd = (process.env.SOLIX_SANDBOX_CMD ?? "").trim();
3385
+ if (!cmd) return { file, args };
3386
+ const parts = cmd.split(/\s+/);
3387
+ const bin = parts[0];
3388
+ return { file: bin, args: [...parts.slice(1), file, ...args] };
3389
+ }
2912
3390
  var Launcher = class {
2913
3391
  constructor(db, broadcaster) {
2914
3392
  this.db = db;
@@ -2938,15 +3416,17 @@ var Launcher = class {
2938
3416
  return this.pinSynthetic(advisor.id, advisor.codename, cwd);
2939
3417
  }
2940
3418
  try {
2941
- const child = spawn(
2942
- "claude",
2943
- ["--agent", advisor.id, "--no-tty"],
2944
- {
2945
- cwd,
2946
- stdio: ["pipe", "pipe", "pipe"],
2947
- detached: false
2948
- }
2949
- );
3419
+ const spawnSpec = sandboxWrap("claude", [
3420
+ "--agent",
3421
+ advisor.id,
3422
+ "--no-tty"
3423
+ ]);
3424
+ const child = spawn(spawnSpec.file, spawnSpec.args, {
3425
+ cwd,
3426
+ stdio: ["pipe", "pipe", "pipe"],
3427
+ detached: false,
3428
+ env: buildSpawnEnv()
3429
+ });
2950
3430
  const pid = child.pid;
2951
3431
  if (!pid) {
2952
3432
  this.broadcaster.broadcast({
@@ -2985,7 +3465,7 @@ var Launcher = class {
2985
3465
  }
2986
3466
  pinSynthetic(advisorId, codename, cwd) {
2987
3467
  const project = ensureProject(this.db, cwd);
2988
- const sessionId = `advisor-${advisorId}-${nanoid5(6)}`;
3468
+ const sessionId = `advisor-${advisorId}-${nanoid7(6)}`;
2989
3469
  const fakePid = 1e5 + Math.floor(Math.random() * 1e5);
2990
3470
  const session = upsertSession(this.db, {
2991
3471
  id: sessionId,
@@ -3099,7 +3579,9 @@ var Launcher = class {
3099
3579
  cwd: spawnCwd,
3100
3580
  model: opts.model,
3101
3581
  initialPrompt: opts.initialPrompt,
3102
- worktreePath
3582
+ worktreePath,
3583
+ budgetUsd: opts.budgetUsd,
3584
+ goalId: opts.goalId
3103
3585
  });
3104
3586
  }
3105
3587
  if (!existsSync9(spawnCwd)) {
@@ -3113,13 +3595,15 @@ var Launcher = class {
3113
3595
  const args = ["--print"];
3114
3596
  if (opts.model) args.push("--model", String(opts.model));
3115
3597
  args.push(opts.initialPrompt);
3116
- const sessionId = `task-${nanoid5(8)}`;
3598
+ const sessionId = `task-${nanoid7(8)}`;
3117
3599
  return this.spawnPrint({
3118
3600
  sessionId,
3119
3601
  cwd: spawnCwd,
3120
3602
  args,
3121
3603
  isFollowUp: false,
3122
- worktreePath
3604
+ worktreePath,
3605
+ budgetUsd: opts.budgetUsd,
3606
+ goalId: opts.goalId
3123
3607
  });
3124
3608
  }
3125
3609
  /**
@@ -3152,10 +3636,12 @@ var Launcher = class {
3152
3636
  args.push("--bg", opts.initialPrompt);
3153
3637
  let child;
3154
3638
  try {
3155
- child = spawn("claude", args, {
3639
+ const spawnSpec = sandboxWrap("claude", args);
3640
+ child = spawn(spawnSpec.file, spawnSpec.args, {
3156
3641
  cwd: opts.cwd,
3157
3642
  stdio: ["ignore", "pipe", "pipe"],
3158
- detached: false
3643
+ detached: false,
3644
+ env: buildSpawnEnv()
3159
3645
  });
3160
3646
  } catch (err) {
3161
3647
  this.broadcaster.broadcast({
@@ -3199,6 +3685,15 @@ var Launcher = class {
3199
3685
  });
3200
3686
  return false;
3201
3687
  }
3688
+ const full = getSession(this.db, sessionId);
3689
+ if (full?.budgetUsd != null && full.costUsd >= full.budgetUsd) {
3690
+ this.broadcaster.broadcast({
3691
+ type: "toast",
3692
+ level: "warn",
3693
+ message: `Budget reached for ${full.name ?? sessionId.slice(0, 8)} ($${full.costUsd.toFixed(2)}/$${full.budgetUsd.toFixed(2)}). Raise the cap to continue.`
3694
+ });
3695
+ return false;
3696
+ }
3202
3697
  if (FAKE_CLAUDE) {
3203
3698
  this.broadcaster.broadcast({
3204
3699
  type: "chat_delta",
@@ -3230,13 +3725,29 @@ var Launcher = class {
3230
3725
  }
3231
3726
  return void 0;
3232
3727
  }
3728
+ /** Sprint M — budget cap recorded at launch for a cwd, if any. */
3729
+ budgetForInternalCwd(cwd) {
3730
+ for (const rec of this.internalTasks.values()) {
3731
+ if (rec.cwd === cwd && rec.budgetUsd != null) return rec.budgetUsd;
3732
+ }
3733
+ return void 0;
3734
+ }
3735
+ /** Sprint M — goal recorded at launch for a cwd, if any. */
3736
+ goalForInternalCwd(cwd) {
3737
+ for (const rec of this.internalTasks.values()) {
3738
+ if (rec.cwd === cwd && rec.goalId) return rec.goalId;
3739
+ }
3740
+ return void 0;
3741
+ }
3233
3742
  spawnPrint(opts) {
3234
3743
  let child;
3235
3744
  try {
3236
- child = spawn("claude", opts.args, {
3745
+ const spawnSpec = sandboxWrap("claude", opts.args);
3746
+ child = spawn(spawnSpec.file, spawnSpec.args, {
3237
3747
  cwd: opts.cwd,
3238
3748
  stdio: ["ignore", "pipe", "pipe"],
3239
- detached: false
3749
+ detached: false,
3750
+ env: buildSpawnEnv()
3240
3751
  });
3241
3752
  } catch (err) {
3242
3753
  this.broadcaster.broadcast({
@@ -3251,7 +3762,9 @@ var Launcher = class {
3251
3762
  if (!opts.isFollowUp) {
3252
3763
  this.internalTasks.set(opts.sessionId, {
3253
3764
  cwd: opts.cwd,
3254
- worktreePath: opts.worktreePath
3765
+ worktreePath: opts.worktreePath,
3766
+ budgetUsd: opts.budgetUsd,
3767
+ goalId: opts.goalId
3255
3768
  });
3256
3769
  }
3257
3770
  let stdout = "";
@@ -3297,7 +3810,7 @@ var Launcher = class {
3297
3810
  }
3298
3811
  launchSynthetic(opts) {
3299
3812
  const project = ensureProject(this.db, opts.cwd);
3300
- const sessionId = `task-${nanoid5(8)}`;
3813
+ const sessionId = `task-${nanoid7(8)}`;
3301
3814
  const fakePid = 2e5 + Math.floor(Math.random() * 1e5);
3302
3815
  upsertSession(this.db, {
3303
3816
  id: sessionId,
@@ -3308,6 +3821,8 @@ var Launcher = class {
3308
3821
  model: opts.model ?? "sonnet",
3309
3822
  worktreePath: opts.worktreePath
3310
3823
  });
3824
+ if (opts.budgetUsd != null) setSessionBudget(this.db, sessionId, opts.budgetUsd);
3825
+ if (opts.goalId) setSessionGoal(this.db, sessionId, opts.goalId);
3311
3826
  const active = setSessionStatus(this.db, sessionId, "active");
3312
3827
  if (active)
3313
3828
  this.broadcaster.broadcast({ type: "session_upsert", session: active });
@@ -3343,12 +3858,12 @@ var Launcher = class {
3343
3858
  };
3344
3859
 
3345
3860
  // ../server/src/router.ts
3346
- import { nanoid as nanoid7 } from "nanoid";
3861
+ import { nanoid as nanoid9 } from "nanoid";
3347
3862
 
3348
3863
  // ../server/src/state/toolcalls.ts
3349
- import { nanoid as nanoid6 } from "nanoid";
3864
+ import { nanoid as nanoid8 } from "nanoid";
3350
3865
  function recordToolCall(db, input) {
3351
- const id = nanoid6();
3866
+ const id = nanoid8();
3352
3867
  const ts2 = now();
3353
3868
  const status = input.status ?? "running";
3354
3869
  db.prepare(
@@ -3450,6 +3965,8 @@ var EventRouter = class {
3450
3965
  const sessionId = this.extractSessionId(event);
3451
3966
  const advisorRole = this.launcher?.advisorRoleForPid(event.pid);
3452
3967
  const worktreePath = this.launcher?.worktreePathForInternalCwd(event.cwd);
3968
+ const launchBudget = this.launcher?.budgetForInternalCwd(event.cwd);
3969
+ const launchGoal = this.launcher?.goalForInternalCwd(event.cwd);
3453
3970
  const wrapper = claimWrapperForCwd(event.cwd);
3454
3971
  const session = upsertSession(this.db, {
3455
3972
  id: sessionId,
@@ -3465,7 +3982,14 @@ var EventRouter = class {
3465
3982
  wrapperSocketPath: wrapper?.socketPath
3466
3983
  });
3467
3984
  if (wrapper) bindWrapperToSession(wrapper.wrapperId, session.id);
3468
- this.broadcaster.broadcast({ type: "session_upsert", session });
3985
+ let enriched = session;
3986
+ if (launchBudget != null) {
3987
+ enriched = setSessionBudget(this.db, session.id, launchBudget) ?? enriched;
3988
+ }
3989
+ if (launchGoal) {
3990
+ enriched = setSessionGoal(this.db, session.id, launchGoal) ?? enriched;
3991
+ }
3992
+ this.broadcaster.broadcast({ type: "session_upsert", session: enriched });
3469
3993
  if (!session.parentSessionId) {
3470
3994
  this.transcripts?.startWatching(sessionId, event.cwd);
3471
3995
  }
@@ -3486,7 +4010,7 @@ var EventRouter = class {
3486
4010
  model: this.extractModel(event)
3487
4011
  });
3488
4012
  }
3489
- const mission = startMission(this.db, sessionId, prompt);
4013
+ const mission = startMission(this.db, sessionId, prompt, session.currentGoalId);
3490
4014
  const updated = setSessionMission(this.db, sessionId, mission.id);
3491
4015
  const active = updated ? setSessionStatus(this.db, sessionId, "active") : null;
3492
4016
  this.broadcaster.broadcast({ type: "mission_upsert", mission });
@@ -3530,7 +4054,7 @@ var EventRouter = class {
3530
4054
  const parentSessionId = this.extractSessionId(event);
3531
4055
  const parent = getSession(this.db, parentSessionId);
3532
4056
  if (!parent) return;
3533
- const subId = nanoid7();
4057
+ const subId = nanoid9();
3534
4058
  const sub = upsertSession(this.db, {
3535
4059
  id: subId,
3536
4060
  pid: event.pid,
@@ -3607,7 +4131,7 @@ var EventRouter = class {
3607
4131
  const p = event.payload;
3608
4132
  const message = typeof p.message === "string" ? p.message : "Permission requested";
3609
4133
  const tool = typeof p.tool_name === "string" ? p.tool_name : "unknown";
3610
- const requestId = nanoid7();
4134
+ const requestId = nanoid9();
3611
4135
  this.permissions.set(requestId, {
3612
4136
  requestId,
3613
4137
  sessionId,
@@ -3635,6 +4159,90 @@ var EventRouter = class {
3635
4159
  message: `Permission requested: ${message}`
3636
4160
  });
3637
4161
  }
4162
+ /**
4163
+ * Synchronous human-in-the-loop gate for the blocking PreToolUse path.
4164
+ * Records the tool call (so the comet/timeline visuals still fire — the gate
4165
+ * hook no longer POSTs to /events), broadcasts a permission_request, and
4166
+ * returns a promise that resolves when a human answers via `permission_response`
4167
+ * (reused unchanged) or when the server-side timeout fires.
4168
+ */
4169
+ requestPermission(event) {
4170
+ const sessionId = this.extractSessionId(event);
4171
+ const p = event.payload;
4172
+ const { tool, args } = this.describeGatedTool(event.event, p);
4173
+ const session = getSession(this.db, sessionId);
4174
+ if (session) {
4175
+ const toolCall = recordToolCall(this.db, {
4176
+ sessionId,
4177
+ missionId: session.currentMissionId,
4178
+ tool,
4179
+ args
4180
+ });
4181
+ if (event.event === "pre_tool_file" && session.currentMissionId && typeof args.file_path === "string" && args.file_path) {
4182
+ addTouchedFile(this.db, session.currentMissionId, args.file_path);
4183
+ }
4184
+ this.broadcaster.broadcast({ type: "tool_call", toolCall });
4185
+ }
4186
+ const requestId = nanoid9();
4187
+ const timeoutMs = Number(process.env.SOLIX_GATE_TIMEOUT_MS ?? 3e5);
4188
+ return new Promise((resolve4) => {
4189
+ const timer = setTimeout(() => {
4190
+ const pending = this.permissions.get(requestId);
4191
+ if (!pending) return;
4192
+ this.permissions.delete(requestId);
4193
+ const s = setSessionStatus(this.db, sessionId, "active");
4194
+ if (s) this.broadcaster.broadcast({ type: "session_upsert", session: s });
4195
+ resolve4({ approved: false, timedOut: true });
4196
+ }, timeoutMs);
4197
+ this.permissions.set(requestId, {
4198
+ requestId,
4199
+ sessionId,
4200
+ tool,
4201
+ args,
4202
+ createdAt: Date.now(),
4203
+ resolve: (approved) => resolve4({ approved, timedOut: false }),
4204
+ timer
4205
+ });
4206
+ const updated = setSessionStatus(
4207
+ this.db,
4208
+ sessionId,
4209
+ "awaiting_permission"
4210
+ );
4211
+ if (updated) {
4212
+ this.broadcaster.broadcast({ type: "session_upsert", session: updated });
4213
+ }
4214
+ this.broadcaster.broadcast({
4215
+ type: "permission_request",
4216
+ sessionId,
4217
+ tool,
4218
+ args,
4219
+ requestId
4220
+ });
4221
+ this.broadcaster.broadcast({
4222
+ type: "toast",
4223
+ level: "warn",
4224
+ message: `Approval requested: ${tool}`
4225
+ });
4226
+ });
4227
+ }
4228
+ describeGatedTool(eventName, p) {
4229
+ const toolInput = p.tool_input ?? {};
4230
+ if (eventName === "pre_tool_bash") {
4231
+ const command = typeof p.command === "string" ? p.command : typeof toolInput.command === "string" ? toolInput.command : "";
4232
+ return { tool: "Bash", args: { command } };
4233
+ }
4234
+ if (eventName === "pre_tool_file") {
4235
+ const tool2 = typeof p.tool_name === "string" ? p.tool_name : "File";
4236
+ const filePath = typeof p.file_path === "string" ? p.file_path : typeof toolInput.file_path === "string" ? toolInput.file_path : "";
4237
+ return { tool: tool2, args: { file_path: filePath } };
4238
+ }
4239
+ if (eventName === "pre_tool_task") {
4240
+ const tool2 = typeof p.tool_name === "string" ? p.tool_name : "Task";
4241
+ return { tool: tool2, args: toolInput };
4242
+ }
4243
+ const tool = typeof p.tool_name === "string" ? p.tool_name : "tool";
4244
+ return { tool, args: toolInput };
4245
+ }
3638
4246
  invokeAdvisor(advisorId, targetSessionId, prompt) {
3639
4247
  const advisor = getAdvisor(this.db, advisorId);
3640
4248
  if (!advisor) return { ok: false };
@@ -3687,6 +4295,20 @@ var EventRouter = class {
3687
4295
  }
3688
4296
  return ok;
3689
4297
  }
4298
+ /** Sprint N — enable/disable an advisor and broadcast the change so open
4299
+ * browsers (and the CLI path) update live. Returns the updated advisor. */
4300
+ setAdvisorEnabled(advisorId, enabled) {
4301
+ const advisor = setAdvisorEnabled(this.db, advisorId, enabled);
4302
+ if (advisor) {
4303
+ this.broadcaster.broadcast({ type: "advisor_upsert", advisor });
4304
+ this.broadcaster.broadcast({
4305
+ type: "toast",
4306
+ level: "info",
4307
+ message: `${advisor.codename} ${enabled ? "added to crew" : "disabled"}`
4308
+ });
4309
+ }
4310
+ return advisor;
4311
+ }
3690
4312
  unpinAdvisor(advisorId) {
3691
4313
  if (this.launcher) {
3692
4314
  this.launcher.unpin(advisorId);
@@ -3708,6 +4330,8 @@ var EventRouter = class {
3708
4330
  const pending = this.permissions.get(requestId);
3709
4331
  if (!pending) return false;
3710
4332
  this.permissions.delete(requestId);
4333
+ if (pending.timer) clearTimeout(pending.timer);
4334
+ if (pending.resolve) pending.resolve(approved);
3711
4335
  const status = approved ? "active" : "idle";
3712
4336
  const session = setSessionStatus(this.db, pending.sessionId, status);
3713
4337
  if (session)
@@ -3745,7 +4369,27 @@ var EventRouter = class {
3745
4369
  worktreeBranch: opts.worktreeBranch,
3746
4370
  worktreeBaseRef: opts.worktreeBaseRef,
3747
4371
  useAgentView: opts.useAgentView,
3748
- agentName: opts.agentName
4372
+ agentName: opts.agentName,
4373
+ budgetUsd: opts.budgetUsd,
4374
+ goalId: opts.goalId
4375
+ });
4376
+ }
4377
+ /** Sprint M — raise (or clear) a session's budget cap. Clears any standing
4378
+ * budget breach by re-broadcasting the current cost against the new cap. */
4379
+ raiseBudget(sessionId, budgetUsd) {
4380
+ const session = setSessionBudget(this.db, sessionId, budgetUsd);
4381
+ if (!session) return;
4382
+ this.broadcaster.broadcast({ type: "session_upsert", session });
4383
+ this.broadcaster.broadcast({
4384
+ type: "cost_update",
4385
+ sessionId,
4386
+ costUsd: session.costUsd,
4387
+ budgetUsd: session.budgetUsd
4388
+ });
4389
+ this.broadcaster.broadcast({
4390
+ type: "toast",
4391
+ level: "info",
4392
+ message: `Budget raised to $${budgetUsd.toFixed(2)} for ${session.name ?? sessionId.slice(0, 8)}`
3749
4393
  });
3750
4394
  }
3751
4395
  sendPromptToSession(sessionId, text) {
@@ -3790,6 +4434,19 @@ var EventRouter = class {
3790
4434
  broadcastSessionUpsert(session) {
3791
4435
  this.broadcaster.broadcast({ type: "session_upsert", session });
3792
4436
  }
4437
+ // Sprint M — broadcast helpers for schedule/goal CRUD driven by HTTP/CLI.
4438
+ broadcastScheduleUpsert(schedule2) {
4439
+ this.broadcaster.broadcast({ type: "schedule_upsert", schedule: schedule2 });
4440
+ }
4441
+ broadcastScheduleRemove(scheduleId) {
4442
+ this.broadcaster.broadcast({ type: "schedule_remove", scheduleId });
4443
+ }
4444
+ broadcastGoalUpsert(goal2) {
4445
+ this.broadcaster.broadcast({ type: "goal_upsert", goal: goal2 });
4446
+ }
4447
+ broadcastGoalRemove(goalId) {
4448
+ this.broadcaster.broadcast({ type: "goal_remove", goalId });
4449
+ }
3793
4450
  broadcastGalaxyImported(manifest) {
3794
4451
  this.broadcaster.broadcast({ type: "galaxy_imported", manifest });
3795
4452
  this.broadcaster.broadcast({
@@ -3845,7 +4502,9 @@ function attachWs(server, ctx) {
3845
4502
  sessions: listActiveSessions(ctx.db),
3846
4503
  missions: listMissions(ctx.db, { limit: 100 }),
3847
4504
  advisors: listAdvisors(ctx.db),
3848
- skills: listSkills(ctx.db)
4505
+ skills: listSkills(ctx.db),
4506
+ schedules: listSchedules(ctx.db),
4507
+ goals: listGoals(ctx.db)
3849
4508
  };
3850
4509
  ctx.broadcaster.send(ws, snapshot);
3851
4510
  for (const p of ctx.router.pendingPermissions()) {
@@ -3894,9 +4553,16 @@ function handleClientMessage(ctx, _ws, msg) {
3894
4553
  worktreeBranch: msg.worktreeBranch,
3895
4554
  worktreeBaseRef: msg.worktreeBaseRef,
3896
4555
  useAgentView: msg.useAgentView,
3897
- agentName: msg.agentName
4556
+ agentName: msg.agentName,
4557
+ budgetUsd: msg.budgetUsd,
4558
+ goalId: msg.goalId
3898
4559
  });
3899
4560
  break;
4561
+ case "raise_budget":
4562
+ ctx.router.raiseBudget(msg.sessionId, msg.budgetUsd);
4563
+ break;
4564
+ case "dismiss_budget_alert":
4565
+ break;
3900
4566
  case "invoke_advisor":
3901
4567
  ctx.router.invokeAdvisor(
3902
4568
  msg.advisorId,
@@ -3910,6 +4576,9 @@ function handleClientMessage(ctx, _ws, msg) {
3910
4576
  case "unpin_advisor":
3911
4577
  ctx.router.unpinAdvisor(msg.advisorId);
3912
4578
  break;
4579
+ case "set_advisor_enabled":
4580
+ ctx.router.setAdvisorEnabled(msg.advisorId, msg.enabled);
4581
+ break;
3913
4582
  default:
3914
4583
  break;
3915
4584
  }
@@ -3945,12 +4614,15 @@ var TranscriptWatcherManager = class {
3945
4614
  constructor(db, broadcaster) {
3946
4615
  this.db = db;
3947
4616
  this.broadcaster = broadcaster;
3948
- void this.db;
3949
4617
  }
3950
4618
  db;
3951
4619
  broadcaster;
3952
4620
  records = /* @__PURE__ */ new Map();
3953
4621
  deferredRetry = /* @__PURE__ */ new Map();
4622
+ // Sprint M: sessions we've already raised a budget alert for, so we don't
4623
+ // re-fire on every subsequent assistant message. Cleared when spend drops
4624
+ // back under the cap (e.g. after the cap is raised).
4625
+ budgetAlerted = /* @__PURE__ */ new Set();
3954
4626
  /**
3955
4627
  * Begin tailing this session's transcript. Idempotent. If the file doesn't
3956
4628
  * exist yet, retries every second for up to 10 s (Claude Code creates the
@@ -4082,6 +4754,39 @@ var TranscriptWatcherManager = class {
4082
4754
  sessionId,
4083
4755
  usagePct: pct
4084
4756
  });
4757
+ const inc = costForUsage(message.model, message.usage);
4758
+ const session = getSession(this.db, sessionId);
4759
+ if (session) {
4760
+ const updated = setSessionCost(this.db, sessionId, session.costUsd + inc);
4761
+ const costUsd = updated?.costUsd ?? session.costUsd + inc;
4762
+ const cap = updated?.budgetUsd ?? session.budgetUsd;
4763
+ this.broadcaster.broadcast({
4764
+ type: "cost_update",
4765
+ sessionId,
4766
+ costUsd,
4767
+ budgetUsd: cap
4768
+ });
4769
+ if (session.currentMissionId) {
4770
+ addMissionTokens(
4771
+ this.db,
4772
+ session.currentMissionId,
4773
+ totalTokens(message.usage)
4774
+ );
4775
+ }
4776
+ if (cap != null && costUsd >= cap) {
4777
+ if (!this.budgetAlerted.has(sessionId)) {
4778
+ this.budgetAlerted.add(sessionId);
4779
+ this.broadcaster.broadcast({
4780
+ type: "budget_alert",
4781
+ sessionId,
4782
+ costUsd,
4783
+ budgetUsd: cap
4784
+ });
4785
+ }
4786
+ } else {
4787
+ this.budgetAlerted.delete(sessionId);
4788
+ }
4789
+ }
4085
4790
  }
4086
4791
  const content = this.flattenAssistantContent(message.content);
4087
4792
  if (!content) return;
@@ -4367,7 +5072,17 @@ async function createSolixServer(opts = {}) {
4367
5072
  const launcher = new Launcher(db, broadcaster);
4368
5073
  const transcripts = new TranscriptWatcherManager(db, broadcaster);
4369
5074
  const router = new EventRouter(db, broadcaster, launcher, transcripts);
4370
- const app = createHttpApp({ db, router });
5075
+ const tokenPath = join15(
5076
+ process.env.SOLIX_HOME ?? join15(homedir11(), ".solix"),
5077
+ "token"
5078
+ );
5079
+ let token = null;
5080
+ try {
5081
+ token = readFileSync8(tokenPath, "utf8").trim() || null;
5082
+ } catch {
5083
+ token = null;
5084
+ }
5085
+ const app = createHttpApp({ db, router, token });
4371
5086
  const server = serve({
4372
5087
  fetch: app.fetch,
4373
5088
  port,
@@ -4379,10 +5094,31 @@ async function createSolixServer(opts = {}) {
4379
5094
  broadcaster
4380
5095
  });
4381
5096
  const stopAgentViewBridge = startAgentViewBridge({ db, broadcaster });
5097
+ const scheduleTimer = setInterval(() => {
5098
+ try {
5099
+ const due = listDueSchedules(db, now());
5100
+ for (const s of due) {
5101
+ if (!s.cwd) continue;
5102
+ launcher.launch({ cwd: s.cwd, initialPrompt: s.prompt });
5103
+ const updated = markScheduleRun(db, s.id);
5104
+ if (updated) {
5105
+ broadcaster.broadcast({ type: "schedule_upsert", schedule: updated });
5106
+ broadcaster.broadcast({
5107
+ type: "toast",
5108
+ level: "info",
5109
+ message: `Heartbeat fired: ${s.name ?? s.prompt.slice(0, 32)}`
5110
+ });
5111
+ }
5112
+ }
5113
+ } catch (err) {
5114
+ console.warn("[scheduler] tick failed:", err.message);
5115
+ }
5116
+ }, 3e4);
4382
5117
  return {
4383
5118
  port,
4384
5119
  hostname,
4385
5120
  close: () => new Promise((resolve4) => {
5121
+ clearInterval(scheduleTimer);
4386
5122
  stopAgentViewBridge();
4387
5123
  transcripts.shutdownAll();
4388
5124
  launcher.shutdownAll();
@@ -4431,7 +5167,7 @@ async function start(opts = {}) {
4431
5167
  }
4432
5168
 
4433
5169
  // src/uninstall.ts
4434
- import { copyFileSync as copyFileSync2, existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
5170
+ import { copyFileSync as copyFileSync2, existsSync as existsSync12, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
4435
5171
  function uninstall() {
4436
5172
  uninstallShim();
4437
5173
  if (existsSync12(CLAUDE_BACKUP)) {
@@ -4444,7 +5180,7 @@ function uninstall() {
4444
5180
  return;
4445
5181
  }
4446
5182
  const cur = JSON.parse(
4447
- readFileSync8(CLAUDE_SETTINGS, "utf8")
5183
+ readFileSync9(CLAUDE_SETTINGS, "utf8")
4448
5184
  );
4449
5185
  if (cur.hooks) {
4450
5186
  for (const [evt, entries] of Object.entries(cur.hooks)) {
@@ -4460,7 +5196,7 @@ function uninstall() {
4460
5196
 
4461
5197
  // src/index.ts
4462
5198
  var program = new Command();
4463
- program.name("solix").description("Solix \u2014 a solar-system command center for Claude Code agents").version("1.5.0");
5199
+ program.name("solix").description("Solix \u2014 a solar-system command center for Claude Code agents").version("1.8.0");
4464
5200
  program.command("start", { isDefault: true }).description("Start the Solix server and open the browser").option("-p, --port <port>", "port to listen on", (v) => parseInt(v, 10), 4242).option("--no-open", "do not open browser automatically").action(async (opts) => {
4465
5201
  await start({ port: opts.port, noOpen: !opts.open });
4466
5202
  });
@@ -4529,6 +5265,36 @@ galaxy.command("publish <slug>").description("Publish the current galaxy to the
4529
5265
  galaxy.command("install <slug>").description("Pull and install a galaxy from the configured registry").action(async (slug) => {
4530
5266
  await installFromRegistryCmd(slug);
4531
5267
  });
5268
+ var schedule = program.command("schedule").description('Manage recurring "heartbeat" tasks (Sprint M)');
5269
+ schedule.command("list", { isDefault: true }).description("List all scheduled tasks").action(async () => {
5270
+ await listSchedulesCmd();
5271
+ });
5272
+ schedule.command("add <prompt>").description("Schedule a recurring task").option("--cwd <dir>", "working directory (default: current dir)").option("--every <cadence>", "cadence: 30m, 2h, 1d", "1h").option("--name <name>", "short label for the galaxy node").action(
5273
+ async (prompt, opts) => {
5274
+ await addScheduleCmd(prompt, opts);
5275
+ }
5276
+ );
5277
+ schedule.command("enable <id>").description("Enable a schedule").action(async (id) => {
5278
+ await enableScheduleCmd(id);
5279
+ });
5280
+ schedule.command("disable <id>").description("Disable a schedule (keeps it, stops firing)").action(async (id) => {
5281
+ await disableScheduleCmd(id);
5282
+ });
5283
+ schedule.command("remove <id>").description("Delete a schedule").action(async (id) => {
5284
+ await removeScheduleCmd(id);
5285
+ });
5286
+ var goal = program.command("goal").description("Manage goals that missions roll up to (Sprint M)");
5287
+ goal.command("list", { isDefault: true }).description("List all goals").action(async () => {
5288
+ await listGoalsCmd();
5289
+ });
5290
+ goal.command("add <name>").description("Create a goal").option("--description <desc>", "optional description").option("--color <hex>", "optional hex color for the constellation").action(
5291
+ async (name, opts) => {
5292
+ await addGoalCmd(name, opts);
5293
+ }
5294
+ );
5295
+ goal.command("remove <id>").description("Delete a goal (detaches it from sessions/missions)").action(async (id) => {
5296
+ await removeGoalCmd(id);
5297
+ });
4532
5298
  program.parseAsync(process.argv).catch((err) => {
4533
5299
  console.error(err);
4534
5300
  process.exit(1);