@smart-tinker/kayla 0.1.3 → 0.1.4

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/bot/bot.js CHANGED
@@ -6,7 +6,7 @@ const handlers_1 = require("./handlers");
6
6
  const kayla_1 = require("../core/kayla");
7
7
  function buildBot(deps) {
8
8
  const bot = new grammy_1.Bot(deps.config.telegram.token);
9
- const service = new kayla_1.KaylaService({ cfg: deps.config, logger: deps.logger, storage: deps.storage, api: bot.api });
9
+ const service = new kayla_1.KaylaService({ cfg: deps.config, logger: deps.logger, storage: deps.storage, api: bot.api, semaphore: deps.semaphore });
10
10
  (0, handlers_1.registerHandlers)(bot, { ...deps, service });
11
11
  return bot;
12
12
  }
@@ -22,6 +22,12 @@ function defaultDataDir(env) {
22
22
  return ".kayla-data";
23
23
  }
24
24
  const DEFAULT_CONFIG = {
25
+ web: {
26
+ enabled: false,
27
+ bind: "0.0.0.0",
28
+ port: 17800,
29
+ admin_password: ""
30
+ },
25
31
  telegram: {
26
32
  token: "",
27
33
  mode: "polling",
@@ -109,6 +115,19 @@ function readYamlIfExists(p) {
109
115
  }
110
116
  function applyEnv(cfg, env) {
111
117
  const get = (k) => env[k];
118
+ // Web
119
+ const webEnabled = get("KAYLA_WEB_ENABLED");
120
+ if (webEnabled && webEnabled.trim().length)
121
+ cfg.web.enabled = parseBool(webEnabled);
122
+ const webBind = get("KAYLA_WEB_BIND");
123
+ if (webBind && webBind.trim().length)
124
+ cfg.web.bind = webBind.trim();
125
+ const webPort = get("KAYLA_WEB_PORT");
126
+ if (webPort && webPort.trim().length)
127
+ cfg.web.port = parseNumber(webPort);
128
+ const webPass = get("KAYLA_WEB_ADMIN_PASSWORD");
129
+ if (webPass && webPass.trim().length)
130
+ cfg.web.admin_password = webPass.trim();
112
131
  // Telegram
113
132
  const t = get("KAYLA_TELEGRAM_TOKEN");
114
133
  if (t && t.trim().length)
@@ -201,6 +220,18 @@ function applyEnv(cfg, env) {
201
220
  function applyYaml(cfg, doc) {
202
221
  if (!isRecord(doc))
203
222
  return;
223
+ const w = isRecord(doc.web) ? doc.web : undefined;
224
+ if (w) {
225
+ if (typeof w.enabled === "boolean")
226
+ cfg.web.enabled = w.enabled;
227
+ const bind = optionalString(w.bind);
228
+ if (bind)
229
+ cfg.web.bind = bind;
230
+ if (typeof w.port === "number")
231
+ cfg.web.port = w.port;
232
+ if (typeof w.admin_password === "string")
233
+ cfg.web.admin_password = String(w.admin_password);
234
+ }
204
235
  const t = isRecord(doc.telegram) ? doc.telegram : undefined;
205
236
  if (t) {
206
237
  const token = optionalString(t.token);
@@ -287,6 +318,11 @@ function validate(cfg) {
287
318
  throw new Error("Invalid config: jobs.global_concurrency must be >= 1");
288
319
  // Derive output_format from streaming by default (kept for compatibility).
289
320
  cfg.claude.output_format = cfg.claude.streaming ? "stream-json" : "json";
321
+ if (cfg.web.enabled) {
322
+ if (!cfg.web.admin_password || cfg.web.admin_password.trim().length === 0) {
323
+ throw new Error("Missing web admin password: set web.admin_password in config.yaml or KAYLA_WEB_ADMIN_PASSWORD in env");
324
+ }
325
+ }
290
326
  if (!cfg.telegram.token || cfg.telegram.token.trim().length === 0) {
291
327
  throw new Error("Missing telegram token: set telegram.token in config.yaml or KAYLA_TELEGRAM_TOKEN in env");
292
328
  }
@@ -4,34 +4,12 @@ exports.KaylaService = void 0;
4
4
  const sessions_1 = require("./sessions");
5
5
  const runner_1 = require("./runner");
6
6
  const telegram_1 = require("./telegram");
7
- class Semaphore {
8
- constructor(size) {
9
- this.waiters = [];
10
- this.available = size;
11
- }
12
- async acquire() {
13
- if (this.available > 0) {
14
- this.available -= 1;
15
- return () => this.release();
16
- }
17
- return new Promise((resolve) => {
18
- this.waiters.push(resolve);
19
- });
20
- }
21
- release() {
22
- this.available += 1;
23
- const next = this.waiters.shift();
24
- if (next && this.available > 0) {
25
- this.available -= 1;
26
- next(() => this.release());
27
- }
28
- }
29
- }
7
+ const limiter_1 = require("./limiter");
30
8
  class KaylaService {
31
9
  constructor(deps) {
32
10
  this.deps = deps;
33
11
  this.chats = new Map();
34
- this.semaphore = new Semaphore(this.deps.cfg.jobs.global_concurrency);
12
+ this.semaphore = this.deps.semaphore ?? new limiter_1.Semaphore(this.deps.cfg.jobs.global_concurrency);
35
13
  this.runClaudeImpl = this.deps.runClaudeImpl ?? runner_1.runClaude;
36
14
  }
37
15
  isAllowedTelegramUser(userId) {
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Semaphore = void 0;
4
+ class Semaphore {
5
+ constructor(size) {
6
+ this.waiters = [];
7
+ this.available = size;
8
+ }
9
+ async acquire() {
10
+ if (this.available > 0) {
11
+ this.available -= 1;
12
+ return () => this.release();
13
+ }
14
+ return new Promise((resolve) => {
15
+ this.waiters.push(resolve);
16
+ });
17
+ }
18
+ release() {
19
+ this.available += 1;
20
+ const next = this.waiters.shift();
21
+ if (next && this.available > 0) {
22
+ this.available -= 1;
23
+ next(() => this.release());
24
+ }
25
+ }
26
+ }
27
+ exports.Semaphore = Semaphore;
@@ -3,6 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getOrCreateSessionByIds = getOrCreateSessionByIds;
7
+ exports.createNewSessionByIds = createNewSessionByIds;
8
+ exports.resetSessionByIds = resetSessionByIds;
6
9
  exports.getOrCreateSession = getOrCreateSession;
7
10
  exports.createNewSession = createNewSession;
8
11
  exports.resetSession = resetSession;
@@ -21,9 +24,7 @@ function defaultWorkspacePath(cfg, chatId) {
21
24
  function newWorkspacePath(cfg, chatId) {
22
25
  return node_path_1.default.join(cfg.runtime.workspaces_dir, `${safeWorkspaceName(chatId)}-${Date.now()}`);
23
26
  }
24
- function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
25
- const chatId = String(chatIdRaw);
26
- const userId = String(userIdRaw);
27
+ function getOrCreateSessionByIds(storage, cfg, chatId, userId) {
27
28
  const existing = storage.getChat(chatId);
28
29
  if (existing) {
29
30
  ensureDir(existing.workspace_path);
@@ -34,12 +35,11 @@ function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
34
35
  const inserted = storage.upsertChat({ chatId, userId, workspacePath, settings: {} });
35
36
  return { chatId, userId: inserted.user_id, workspacePath: inserted.workspace_path };
36
37
  }
37
- function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
38
- const chatId = String(chatIdRaw);
38
+ function createNewSessionByIds(storage, cfg, chatId, userId) {
39
39
  const existing = storage.getChat(chatId);
40
40
  if (!existing) {
41
41
  // If no chat yet, "new" is equivalent to create.
42
- return getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
42
+ return getOrCreateSessionByIds(storage, cfg, chatId, userId);
43
43
  }
44
44
  let workspacePath = newWorkspacePath(cfg, chatId);
45
45
  // Extremely unlikely, but avoid collisions in fast loops.
@@ -50,10 +50,19 @@ function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
50
50
  const updated = storage.updateChatWorkspace(chatId, workspacePath);
51
51
  return { chatId, userId: updated.user_id, workspacePath: updated.workspace_path };
52
52
  }
53
- function resetSession(storage, cfg, chatIdRaw, userIdRaw) {
54
- const session = getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
53
+ function resetSessionByIds(storage, cfg, chatId, userId) {
54
+ const session = getOrCreateSessionByIds(storage, cfg, chatId, userId);
55
55
  node_fs_1.default.rmSync(session.workspacePath, { recursive: true, force: true });
56
56
  ensureDir(session.workspacePath);
57
57
  // Workspace path remains the same; just return current mapping.
58
- return getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
58
+ return getOrCreateSessionByIds(storage, cfg, chatId, userId);
59
+ }
60
+ function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
61
+ return getOrCreateSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
62
+ }
63
+ function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
64
+ return createNewSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
65
+ }
66
+ function resetSession(storage, cfg, chatIdRaw, userIdRaw) {
67
+ return resetSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
59
68
  }
@@ -68,7 +68,14 @@ function buildConfigFromBootstrapInputs(paths, inputs) {
68
68
  if (adminIds.length === 0)
69
69
  throw new Error("Missing telegram admin user ids");
70
70
  const allowIds = inputs.telegramAllowlistUserIds;
71
+ const webPass = inputs.webAdminPassword.trim();
71
72
  return {
73
+ web: {
74
+ enabled: webPass.length > 0,
75
+ bind: "0.0.0.0",
76
+ port: 17800,
77
+ admin_password: webPass
78
+ },
72
79
  telegram: {
73
80
  token,
74
81
  mode: "polling",
@@ -115,7 +122,16 @@ function parseBootstrapInputsFromEnv(env) {
115
122
  throw new Error("Invalid KAYLA_TELEGRAM_ADMIN_USER_IDS: empty list");
116
123
  const allowCsv = (env.KAYLA_TELEGRAM_ALLOWLIST_USER_IDS ?? "").trim();
117
124
  const allowIds = allowCsv ? parseCsvNumbersStrict(allowCsv) : [];
118
- return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds };
125
+ const webEnabledRaw = (env.KAYLA_WEB_ENABLED ?? "").trim();
126
+ const webEnabled = webEnabledRaw ? ["1", "true", "yes", "on"].includes(webEnabledRaw.toLowerCase()) : undefined;
127
+ const webDisabled = webEnabledRaw ? ["0", "false", "no", "off"].includes(webEnabledRaw.toLowerCase()) : undefined;
128
+ if (webEnabledRaw && webEnabled === undefined && webDisabled === undefined)
129
+ throw new Error(`Invalid KAYLA_WEB_ENABLED: ${webEnabledRaw}`);
130
+ const webPass = (env.KAYLA_WEB_ADMIN_PASSWORD ?? "").trim();
131
+ if (webEnabled && !webPass)
132
+ throw new Error("Missing KAYLA_WEB_ADMIN_PASSWORD (required when KAYLA_WEB_ENABLED=true)");
133
+ const webAdminPassword = webDisabled ? "" : webPass;
134
+ return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds, webAdminPassword };
119
135
  }
120
136
  function buildConfigFromEnv(paths, env) {
121
137
  const inputs = parseBootstrapInputsFromEnv(env);
@@ -152,7 +168,8 @@ async function promptBootstrapInputs(prompt) {
152
168
  }
153
169
  }
154
170
  })();
155
- return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds };
171
+ const webAdminPassword = (await prompt({ kind: "secret", message: "Web admin password (blank = disable web UI): " })).trim();
172
+ return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds, webAdminPassword };
156
173
  }
157
174
  function nonInteractiveSetupError(configPath) {
158
175
  return [
package/dist/service.js CHANGED
@@ -3,8 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runService = runService;
4
4
  const bot_1 = require("./bot/bot");
5
5
  const config_1 = require("./core/config");
6
+ const doctor_1 = require("./core/doctor");
7
+ const limiter_1 = require("./core/limiter");
6
8
  const logging_1 = require("./core/logging");
7
9
  const storage_1 = require("./core/storage");
10
+ const server_1 = require("./web/server");
8
11
  async function runService() {
9
12
  const config = (0, config_1.loadConfig)();
10
13
  const logger = (0, logging_1.createLogger)(config.logging);
@@ -12,7 +15,12 @@ async function runService() {
12
15
  const storage = (0, storage_1.openStorage)(config.runtime.data_dir);
13
16
  // Bootstrap allowlist into DB (required for first admin).
14
17
  storage.bootstrapAllowedUsers("telegram", [...config.telegram.admin_user_ids, ...config.telegram.allowlist.user_ids]);
15
- const bot = (0, bot_1.buildBot)({ config, logger, storage });
18
+ const semaphore = new limiter_1.Semaphore(config.jobs.global_concurrency);
19
+ const bot = (0, bot_1.buildBot)({ config, logger, storage, semaphore });
20
+ if (config.web.enabled) {
21
+ const configPath = (0, doctor_1.resolveConfigPath)(process.env);
22
+ await (0, server_1.startWebServer)({ config, configPath, logger, storage, semaphore });
23
+ }
16
24
  // MVP: polling only.
17
25
  if (config.telegram.mode !== "polling") {
18
26
  throw new Error(`telegram.mode=${config.telegram.mode} is not supported yet (MVP supports polling only)`);
@@ -0,0 +1,834 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startWebServer = startWebServer;
7
+ const node_http_1 = __importDefault(require("node:http"));
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const yaml_1 = __importDefault(require("yaml"));
11
+ const doctor_1 = require("../core/doctor");
12
+ const runner_1 = require("../core/runner");
13
+ const sessions_1 = require("../core/sessions");
14
+ function unauthorized(res) {
15
+ res.statusCode = 401;
16
+ res.setHeader("WWW-Authenticate", 'Basic realm="Kayla"');
17
+ res.end("Unauthorized");
18
+ }
19
+ function parseBasicAuthHeader(v) {
20
+ if (typeof v !== "string")
21
+ return null;
22
+ const [kind, token] = v.split(" ");
23
+ if (kind !== "Basic" || !token)
24
+ return null;
25
+ let raw = "";
26
+ try {
27
+ raw = Buffer.from(token, "base64").toString("utf8");
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ const idx = raw.indexOf(":");
33
+ if (idx < 0)
34
+ return null;
35
+ const username = raw.slice(0, idx);
36
+ const password = raw.slice(idx + 1);
37
+ return { username, password };
38
+ }
39
+ function isAuthorized(req, adminPassword) {
40
+ const auth = parseBasicAuthHeader(req.headers.authorization);
41
+ if (!auth)
42
+ return false;
43
+ // Username is ignored for now (single-admin mode).
44
+ return auth.password === adminPassword;
45
+ }
46
+ function sendJson(res, status, body) {
47
+ res.statusCode = status;
48
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
49
+ res.end(JSON.stringify(body));
50
+ }
51
+ function sendHtml(res, status, html) {
52
+ res.statusCode = status;
53
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
54
+ res.end(html);
55
+ }
56
+ function readBody(req, maxBytes) {
57
+ return new Promise((resolve, reject) => {
58
+ const chunks = [];
59
+ let size = 0;
60
+ req.on("data", (c) => {
61
+ size += c.length;
62
+ if (size > maxBytes) {
63
+ reject(new Error("Request body too large"));
64
+ req.destroy();
65
+ return;
66
+ }
67
+ chunks.push(c);
68
+ });
69
+ req.on("end", () => resolve(Buffer.concat(chunks)));
70
+ req.on("error", reject);
71
+ });
72
+ }
73
+ async function readJsonBody(req, maxBytes) {
74
+ const buf = await readBody(req, maxBytes);
75
+ if (buf.length === 0)
76
+ return {};
77
+ try {
78
+ return JSON.parse(buf.toString("utf8"));
79
+ }
80
+ catch {
81
+ throw new Error("Invalid JSON");
82
+ }
83
+ }
84
+ function parseCsvNumbersBestEffort(v) {
85
+ if (typeof v !== "string")
86
+ return [];
87
+ const items = v
88
+ .split(",")
89
+ .map((x) => x.trim())
90
+ .filter((x) => x.length > 0);
91
+ const out = [];
92
+ for (const it of items) {
93
+ const n = Number(it);
94
+ if (!Number.isFinite(n))
95
+ continue;
96
+ out.push(n);
97
+ }
98
+ return out;
99
+ }
100
+ function maskSecret(v) {
101
+ if (!v.trim())
102
+ return "";
103
+ // Fixed-length mask to avoid leaking secret length in UI.
104
+ return "********";
105
+ }
106
+ function renderIndexHtml() {
107
+ // Keep it dependency-free; minimal JS to fetch/save settings.
108
+ return `<!doctype html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
113
+ <title>Kayla Admin</title>
114
+ <style>
115
+ :root { --bg:#0e1116; --panel:#151a22; --text:#e7eefc; --muted:#9bb0d1; --accent:#f6c945; --danger:#ff6673; --ok:#7bf1a8; }
116
+ body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: radial-gradient(1200px 600px at 15% 0%, #1a2440, var(--bg)); color: var(--text); }
117
+ .wrap { max-width: 900px; margin: 0 auto; padding: 28px 16px 48px; }
118
+ h1 { margin: 0 0 6px; font-size: 28px; letter-spacing: 0.2px; }
119
+ .sub { margin: 0 0 22px; color: var(--muted); }
120
+ .panel { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 18px; backdrop-filter: blur(8px); }
121
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
122
+ @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
123
+ label { display:block; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; }
124
+ input, select { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.10); background: rgba(10,12,16,0.6); color: var(--text); outline: none; }
125
+ input:focus, select:focus { border-color: rgba(246,201,69,0.65); box-shadow: 0 0 0 3px rgba(246,201,69,0.18); }
126
+ .row { display:flex; gap: 10px; align-items: center; }
127
+ .row > * { flex: 1; }
128
+ .btn { cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(246,201,69,0.12); color: var(--text); font-weight: 600; }
129
+ .btn:hover { border-color: rgba(246,201,69,0.45); background: rgba(246,201,69,0.18); }
130
+ .btn.secondary { background: rgba(255,255,255,0.06); }
131
+ .btn.danger { background: rgba(255,102,115,0.12); }
132
+ .btn.danger:hover { border-color: rgba(255,102,115,0.45); background: rgba(255,102,115,0.18); }
133
+ .hint { margin-top: 6px; color: var(--muted); font-size: 13px; line-height: 1.35; }
134
+ .status { margin-top: 14px; font-size: 14px; color: var(--muted); }
135
+ .status.ok { color: var(--ok); }
136
+ .status.err { color: var(--danger); }
137
+ .hr { height: 1px; background: rgba(255,255,255,0.08); margin: 16px 0; }
138
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace; }
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <div class="wrap">
143
+ <h1>Kayla Admin</h1>
144
+ <p class="sub">LAN-only HTTP UI. Changes are written to <code>~/.config/kayla/config.yaml</code>.</p>
145
+ <div class="panel">
146
+ <div class="grid">
147
+ <div>
148
+ <label for="telegramToken">Telegram bot token</label>
149
+ <div class="row">
150
+ <input id="telegramToken" type="password" placeholder="(empty)" autocomplete="off" />
151
+ <button class="btn secondary" id="btnShowToken" type="button">Show</button>
152
+ <button class="btn secondary" id="btnToggleMask" type="button">Mask</button>
153
+ </div>
154
+ <div class="hint">Token is masked by default. Use Show to fetch the secret explicitly.</div>
155
+ </div>
156
+ <div>
157
+ <label for="claudeBinary">Claude command/binary</label>
158
+ <input id="claudeBinary" type="text" placeholder="claude" />
159
+ <div class="hint">This is the command Kayla runs (must be in PATH for the service user).</div>
160
+ </div>
161
+ </div>
162
+
163
+ <div class="grid">
164
+ <div>
165
+ <label for="adminIds">Telegram admin user ids (CSV)</label>
166
+ <input id="adminIds" type="text" placeholder="123456789" />
167
+ </div>
168
+ <div>
169
+ <label for="allowIds">Allowlist user ids (CSV, optional)</label>
170
+ <input id="allowIds" type="text" placeholder="(blank = none)" />
171
+ </div>
172
+ </div>
173
+
174
+ <div class="grid">
175
+ <div>
176
+ <label for="loggingLevel">Logging level</label>
177
+ <select id="loggingLevel">
178
+ <option value="fatal">fatal</option>
179
+ <option value="error">error</option>
180
+ <option value="warn">warn</option>
181
+ <option value="info">info</option>
182
+ <option value="debug">debug</option>
183
+ <option value="trace">trace</option>
184
+ </select>
185
+ </div>
186
+ <div>
187
+ <label for="loggingJson">Logging JSON</label>
188
+ <select id="loggingJson">
189
+ <option value="true">true</option>
190
+ <option value="false">false</option>
191
+ </select>
192
+ </div>
193
+ </div>
194
+
195
+ <div class="hr"></div>
196
+ <div class="row">
197
+ <button class="btn secondary" id="btnDoctor" type="button">Doctor</button>
198
+ <button class="btn secondary" id="btnCheckToken" type="button">Check Telegram token</button>
199
+ <button class="btn danger" id="btnRestart" type="button">Restart service</button>
200
+ </div>
201
+ <div class="hint">Restart exits the process; systemd should bring it back (Restart=always).</div>
202
+ <pre id="actionsOut" style="margin-top:12px; padding:12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background: rgba(10,12,16,0.55); overflow:auto; white-space:pre-wrap;"></pre>
203
+ <div class="hr"></div>
204
+ <div class="row">
205
+ <button class="btn" id="btnSave" type="button">Save settings</button>
206
+ </div>
207
+ <div class="status" id="status"></div>
208
+ </div>
209
+ </div>
210
+ <script>
211
+ const $ = (id) => document.getElementById(id);
212
+ const status = (kind, msg) => {
213
+ const el = $("status");
214
+ el.className = "status " + (kind || "");
215
+ el.textContent = msg || "";
216
+ };
217
+
218
+ async function loadSettings() {
219
+ status("", "Loading...");
220
+ const res = await fetch("/api/settings", { credentials: "same-origin" });
221
+ if (!res.ok) throw new Error("Failed to load settings (" + res.status + ")");
222
+ const s = await res.json();
223
+ $("telegramToken").value = s.telegramTokenMasked || "";
224
+ $("adminIds").value = s.telegramAdminUserIdsCsv || "";
225
+ $("allowIds").value = s.telegramAllowlistUserIdsCsv || "";
226
+ $("claudeBinary").value = s.claudeBinary || "claude";
227
+ $("loggingLevel").value = s.loggingLevel || "info";
228
+ $("loggingJson").value = String(Boolean(s.loggingJson));
229
+ status("ok", "Loaded.");
230
+ }
231
+
232
+ async function showToken() {
233
+ status("", "Fetching token...");
234
+ const res = await fetch("/api/secrets/telegram-token", { credentials: "same-origin" });
235
+ if (!res.ok) throw new Error("Failed to fetch token (" + res.status + ")");
236
+ const out = await res.json();
237
+ $("telegramToken").value = out.token || "";
238
+ $("telegramToken").type = "text";
239
+ status("ok", "Token loaded (visible).");
240
+ }
241
+
242
+ function toggleMask() {
243
+ const el = $("telegramToken");
244
+ el.type = (el.type === "password") ? "text" : "password";
245
+ }
246
+
247
+ async function saveSettings() {
248
+ status("", "Saving...");
249
+ const payload = {
250
+ telegramToken: $("telegramToken").value || "",
251
+ telegramAdminUserIdsCsv: $("adminIds").value || "",
252
+ telegramAllowlistUserIdsCsv: $("allowIds").value || "",
253
+ claudeBinary: $("claudeBinary").value || "",
254
+ loggingLevel: $("loggingLevel").value || "info",
255
+ loggingJson: $("loggingJson").value === "true"
256
+ };
257
+ const res = await fetch("/api/settings", {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify(payload),
261
+ credentials: "same-origin"
262
+ });
263
+ const out = await res.json().catch(() => ({}));
264
+ if (!res.ok) throw new Error(out.error || ("Save failed (" + res.status + ")"));
265
+ status("ok", out.message || "Saved. Restart the service to apply changes.");
266
+ }
267
+
268
+ async function runDoctor() {
269
+ status("", "Running doctor...");
270
+ const res = await fetch("/api/doctor", { method: "POST", credentials: "same-origin" });
271
+ const out = await res.json().catch(() => ({}));
272
+ if (!res.ok) throw new Error(out.error || ("Doctor failed (" + res.status + ")"));
273
+ $("actionsOut").textContent = JSON.stringify(out, null, 2);
274
+ status(out.ok ? "ok" : "err", out.ok ? "Doctor: OK" : "Doctor: FAIL");
275
+ }
276
+
277
+ async function checkToken() {
278
+ status("", "Checking Telegram token...");
279
+ const res = await fetch("/api/telegram/check-token", { method: "POST", credentials: "same-origin" });
280
+ const out = await res.json().catch(() => ({}));
281
+ if (!res.ok) throw new Error(out.error || ("Token check failed (" + res.status + ")"));
282
+ $("actionsOut").textContent = JSON.stringify(out, null, 2);
283
+ status(out.ok ? "ok" : "err", out.ok ? "Token: OK" : "Token: FAIL");
284
+ }
285
+
286
+ async function restartService() {
287
+ status("", "Restarting...");
288
+ const res = await fetch("/api/restart", { method: "POST", credentials: "same-origin" });
289
+ const out = await res.json().catch(() => ({}));
290
+ $("actionsOut").textContent = JSON.stringify(out, null, 2);
291
+ status("ok", out.message || "Restarting...");
292
+ }
293
+
294
+ $("btnShowToken").addEventListener("click", () => showToken().catch(e => status("err", String(e && e.message ? e.message : e))));
295
+ $("btnToggleMask").addEventListener("click", () => toggleMask());
296
+ $("btnSave").addEventListener("click", () => saveSettings().catch(e => status("err", String(e && e.message ? e.message : e))));
297
+ $("btnDoctor").addEventListener("click", () => runDoctor().catch(e => status("err", String(e && e.message ? e.message : e))));
298
+ $("btnCheckToken").addEventListener("click", () => checkToken().catch(e => status("err", String(e && e.message ? e.message : e))));
299
+ $("btnRestart").addEventListener("click", () => restartService().catch(e => status("err", String(e && e.message ? e.message : e))));
300
+
301
+ loadSettings().catch(e => status("err", String(e && e.message ? e.message : e)));
302
+ </script>
303
+ </body>
304
+ </html>`;
305
+ }
306
+ function writeYamlAtomic(targetPath, rawYaml) {
307
+ const dir = node_path_1.default.dirname(targetPath);
308
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
309
+ // Temp dir must be on the same filesystem as the target so rename is atomic.
310
+ const tmpDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(dir, ".kayla-web-"));
311
+ const tmpPath = node_path_1.default.join(tmpDir, "config.yaml");
312
+ try {
313
+ // 0600: contains secrets (Telegram token and web password).
314
+ node_fs_1.default.writeFileSync(tmpPath, rawYaml, { mode: 0o600 });
315
+ node_fs_1.default.renameSync(tmpPath, targetPath);
316
+ }
317
+ finally {
318
+ try {
319
+ node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
320
+ }
321
+ catch {
322
+ // ignore cleanup errors
323
+ }
324
+ }
325
+ }
326
+ function renderChatHtml() {
327
+ return `<!doctype html>
328
+ <html lang="en">
329
+ <head>
330
+ <meta charset="utf-8" />
331
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
332
+ <title>Kayla Chat</title>
333
+ <style>
334
+ :root { --bg:#0e1116; --panel:#151a22; --text:#e7eefc; --muted:#9bb0d1; --accent:#f6c945; --danger:#ff6673; --ok:#7bf1a8; }
335
+ body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: radial-gradient(1200px 600px at 15% 0%, #1a2440, var(--bg)); color: var(--text); }
336
+ .wrap { max-width: 980px; margin: 0 auto; padding: 18px 16px 40px; }
337
+ h1 { margin: 0 0 10px; font-size: 26px; }
338
+ .panel { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; backdrop-filter: blur(8px); }
339
+ .row { display:flex; gap: 10px; align-items: center; }
340
+ .btn { cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text); font-weight: 600; }
341
+ .btn:hover { border-color: rgba(246,201,69,0.45); background: rgba(246,201,69,0.10); }
342
+ .btn.danger { background: rgba(255,102,115,0.12); }
343
+ .btn.danger:hover { border-color: rgba(255,102,115,0.45); background: rgba(255,102,115,0.18); }
344
+ .btn.primary { background: rgba(246,201,69,0.14); }
345
+ .hint { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
346
+ #log { margin-top: 12px; padding: 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.08); background: rgba(10,12,16,0.55); min-height: 320px; overflow:auto; white-space: pre-wrap; }
347
+ #input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.10); background: rgba(10,12,16,0.6); color: var(--text); outline: none; }
348
+ #input:focus { border-color: rgba(246,201,69,0.65); box-shadow: 0 0 0 3px rgba(246,201,69,0.18); }
349
+ .status { margin-top: 10px; font-size: 14px; color: var(--muted); }
350
+ .status.ok { color: var(--ok); }
351
+ .status.err { color: var(--danger); }
352
+ </style>
353
+ </head>
354
+ <body>
355
+ <div class="wrap">
356
+ <h1>Kayla Chat</h1>
357
+ <div class="panel">
358
+ <div class="row">
359
+ <button class="btn" id="btnNew" type="button">New session</button>
360
+ <button class="btn danger" id="btnReset" type="button">Reset session</button>
361
+ <button class="btn danger" id="btnCancel" type="button">Cancel</button>
362
+ <button class="btn" id="btnStatus" type="button">Status</button>
363
+ <a class="btn" href="/" style="text-decoration:none;">Admin</a>
364
+ </div>
365
+ <div class="hint">Streaming is via SSE. Reset is destructive and asks for confirmation.</div>
366
+ <div id="log"></div>
367
+ <div class="row" style="margin-top: 12px;">
368
+ <input id="input" type="text" placeholder="Type a message..." autocomplete="off" />
369
+ <button class="btn primary" id="btnSend" type="button">Send</button>
370
+ </div>
371
+ <div class="status" id="status"></div>
372
+ </div>
373
+ </div>
374
+ <script>
375
+ const $ = (id) => document.getElementById(id);
376
+ const logEl = $("log");
377
+ const statusEl = $("status");
378
+
379
+ const setStatus = (kind, msg) => {
380
+ statusEl.className = "status " + (kind || "");
381
+ statusEl.textContent = msg || "";
382
+ };
383
+ const append = (text) => {
384
+ logEl.textContent += text;
385
+ logEl.scrollTop = logEl.scrollHeight;
386
+ };
387
+
388
+ let currentJobId = null;
389
+
390
+ const es = new EventSource("/api/chat/stream");
391
+ es.addEventListener("hello", () => setStatus("ok", "Connected."));
392
+ es.addEventListener("status", (e) => {
393
+ try {
394
+ const d = JSON.parse(e.data);
395
+ append("[status] " + JSON.stringify(d) + "\\n");
396
+ } catch {}
397
+ });
398
+ es.addEventListener("start", (e) => {
399
+ const d = JSON.parse(e.data);
400
+ currentJobId = d.jobId || null;
401
+ append("\\nYou: " + (d.prompt || "") + "\\n");
402
+ append("Kayla: ");
403
+ });
404
+ es.addEventListener("delta", (e) => {
405
+ const d = JSON.parse(e.data);
406
+ if (!currentJobId || d.jobId !== currentJobId) return;
407
+ append(d.delta || "");
408
+ });
409
+ es.addEventListener("done", (e) => {
410
+ const d = JSON.parse(e.data);
411
+ if (d.jobId === currentJobId) currentJobId = null;
412
+ append("\\n\\n[done] " + d.status + (d.error ? (" " + d.error) : "") + "\\n");
413
+ setStatus(d.status === "succeeded" ? "ok" : "err", "Done: " + d.status);
414
+ });
415
+ es.onerror = () => setStatus("err", "Stream disconnected.");
416
+
417
+ async function postJson(url, body) {
418
+ const res = await fetch(url, {
419
+ method: "POST",
420
+ headers: { "Content-Type": "application/json" },
421
+ body: JSON.stringify(body || {}),
422
+ credentials: "same-origin"
423
+ });
424
+ const out = await res.json().catch(() => ({}));
425
+ if (!res.ok) throw new Error(out.error || ("HTTP " + res.status));
426
+ return out;
427
+ }
428
+
429
+ async function send() {
430
+ const input = $("input");
431
+ const text = (input.value || "").trim();
432
+ if (!text) return;
433
+ input.value = "";
434
+ setStatus("", "Sending...");
435
+ await postJson("/api/chat/send", { text });
436
+ setStatus("ok", "Sent.");
437
+ }
438
+
439
+ async function newSession() {
440
+ setStatus("", "Starting new session...");
441
+ const out = await postJson("/api/chat/new", {});
442
+ append("[new] workspace=" + (out.workspacePath || "") + "\\n");
443
+ setStatus("ok", "New session started.");
444
+ }
445
+
446
+ async function resetSession() {
447
+ const ok = confirm("Reset session? This deletes the workspace contents.");
448
+ if (!ok) return;
449
+ setStatus("", "Resetting...");
450
+ const out = await postJson("/api/chat/reset", { confirm: true });
451
+ append("[reset] workspace=" + (out.workspacePath || "") + "\\n");
452
+ setStatus("ok", "Session reset.");
453
+ }
454
+
455
+ async function cancel() {
456
+ setStatus("", "Canceling...");
457
+ const out = await postJson("/api/chat/cancel", {});
458
+ append("[cancel] " + (out.message || "") + "\\n");
459
+ setStatus("ok", out.message || "Cancel requested.");
460
+ }
461
+
462
+ async function statusCmd() {
463
+ setStatus("", "Fetching status...");
464
+ const out = await fetch("/api/chat/status", { credentials: "same-origin" }).then((r) => r.json());
465
+ append("[status] " + JSON.stringify(out) + "\\n");
466
+ setStatus("ok", "OK");
467
+ }
468
+
469
+ $("btnSend").addEventListener("click", () => send().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
470
+ $("input").addEventListener("keydown", (e) => {
471
+ if (e.key === "Enter") send().catch(() => {});
472
+ });
473
+ $("btnNew").addEventListener("click", () => newSession().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
474
+ $("btnReset").addEventListener("click", () => resetSession().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
475
+ $("btnCancel").addEventListener("click", () => cancel().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
476
+ $("btnStatus").addEventListener("click", () => statusCmd().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
477
+ </script>
478
+ </body>
479
+ </html>`;
480
+ }
481
+ class WebChat {
482
+ constructor(deps) {
483
+ this.deps = deps;
484
+ this.chatId = "web:default";
485
+ this.userId = "web:default";
486
+ this.clients = new Set();
487
+ this.queue = [];
488
+ this.processing = false;
489
+ }
490
+ broadcast(event, data) {
491
+ const payload = JSON.stringify(data);
492
+ for (const res of this.clients) {
493
+ try {
494
+ res.write(`event: ${event}\n`);
495
+ res.write(`data: ${payload}\n\n`);
496
+ }
497
+ catch {
498
+ // ignore broken connections; cleanup happens on close
499
+ }
500
+ }
501
+ }
502
+ sendStatus() {
503
+ const running = this.deps.storage.getRunningJob(this.chatId);
504
+ const queued = this.deps.storage.countQueuedJobs(this.chatId);
505
+ const chat = this.deps.storage.getChat(this.chatId);
506
+ this.broadcast("status", {
507
+ running: running ? running.job_id : null,
508
+ queued,
509
+ workspace: chat?.workspace_path ?? null
510
+ });
511
+ }
512
+ handleChatPage(res) {
513
+ sendHtml(res, 200, renderChatHtml());
514
+ }
515
+ handleStream(req, res) {
516
+ res.statusCode = 200;
517
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
518
+ res.setHeader("Cache-Control", "no-cache, no-transform");
519
+ res.setHeader("Connection", "keep-alive");
520
+ res.setHeader("X-Accel-Buffering", "no");
521
+ // Make sure headers are sent immediately for streaming clients.
522
+ res.flushHeaders?.();
523
+ res.write("event: hello\n");
524
+ res.write(`data: ${JSON.stringify({ ok: true })}\n\n`);
525
+ this.clients.add(res);
526
+ this.sendStatus();
527
+ const keep = setInterval(() => {
528
+ try {
529
+ res.write(":keep-alive\n\n");
530
+ }
531
+ catch {
532
+ // ignore
533
+ }
534
+ }, 15000);
535
+ req.on("close", () => {
536
+ clearInterval(keep);
537
+ this.clients.delete(res);
538
+ });
539
+ }
540
+ async handleSend(req, res) {
541
+ const body = await readJsonBody(req, 256 * 1024);
542
+ const b = typeof body === "object" && body !== null ? body : {};
543
+ const text = typeof b.text === "string" ? b.text.trim() : "";
544
+ if (!text)
545
+ return sendJson(res, 400, { ok: false, error: "text is required" });
546
+ const stateCount = this.queue.length + (this.active ? 1 : 0);
547
+ const cfg = this.deps.getConfig();
548
+ if (stateCount >= cfg.jobs.queue_size_per_chat)
549
+ return sendJson(res, 429, { ok: false, error: "Queue is full" });
550
+ // Ensure chat/workspace exists.
551
+ (0, sessions_1.getOrCreateSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
552
+ const job = this.deps.storage.insertJob({ chatId: this.chatId, requestText: text });
553
+ this.queue.push({ jobId: job.job_id, requestText: text });
554
+ this.broadcast("queued", { jobId: job.job_id });
555
+ this.kick().catch((err) => this.deps.logger.error({ msg: "web chat kick failed", err }));
556
+ return sendJson(res, 200, { ok: true, jobId: job.job_id });
557
+ }
558
+ async handleStatus(_req, res) {
559
+ const running = this.deps.storage.getRunningJob(this.chatId);
560
+ const queued = this.deps.storage.countQueuedJobs(this.chatId);
561
+ const chat = this.deps.storage.getChat(this.chatId);
562
+ return sendJson(res, 200, {
563
+ ok: true,
564
+ chatId: this.chatId,
565
+ running: running ? running.job_id : null,
566
+ queued,
567
+ workspace: chat?.workspace_path ?? null
568
+ });
569
+ }
570
+ async handleCancel(_req, res) {
571
+ if (!this.active)
572
+ return sendJson(res, 200, { ok: true, message: "Nothing to cancel." });
573
+ this.active.cancel();
574
+ return sendJson(res, 200, { ok: true, message: `Cancel requested: ${this.active.jobId}` });
575
+ }
576
+ isBusy() {
577
+ return !!this.active || this.queue.length > 0 || this.deps.storage.getRunningJob(this.chatId) !== undefined;
578
+ }
579
+ async handleNew(_req, res) {
580
+ if (this.isBusy())
581
+ return sendJson(res, 409, { ok: false, error: "Busy: cancel the running job first." });
582
+ const cfg = this.deps.getConfig();
583
+ const s = (0, sessions_1.createNewSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
584
+ this.broadcast("info", { msg: "new session", workspace: s.workspacePath });
585
+ return sendJson(res, 200, { ok: true, workspacePath: s.workspacePath });
586
+ }
587
+ async handleReset(req, res) {
588
+ const body = await readJsonBody(req, 32 * 1024);
589
+ const b = typeof body === "object" && body !== null ? body : {};
590
+ const confirm = Boolean(b.confirm);
591
+ if (!confirm)
592
+ return sendJson(res, 400, { ok: false, error: "Confirm reset by sending {confirm:true}" });
593
+ if (this.isBusy())
594
+ return sendJson(res, 409, { ok: false, error: "Busy: cancel the running job first." });
595
+ const cfg = this.deps.getConfig();
596
+ const s = (0, sessions_1.resetSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
597
+ this.broadcast("info", { msg: "session reset", workspace: s.workspacePath });
598
+ return sendJson(res, 200, { ok: true, workspacePath: s.workspacePath });
599
+ }
600
+ async kick() {
601
+ if (this.processing)
602
+ return;
603
+ this.processing = true;
604
+ try {
605
+ while (this.queue.length > 0) {
606
+ const item = this.queue.shift();
607
+ const release = await this.deps.semaphore.acquire();
608
+ try {
609
+ await this.runOne(item);
610
+ }
611
+ finally {
612
+ release();
613
+ }
614
+ }
615
+ }
616
+ finally {
617
+ this.processing = false;
618
+ }
619
+ }
620
+ async runOne(item) {
621
+ const cfg = this.deps.getConfig();
622
+ const session = (0, sessions_1.getOrCreateSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
623
+ const logger = this.deps.logger.child({ channel: "web", jobId: item.jobId });
624
+ this.deps.storage.setJobRunning(item.jobId);
625
+ this.broadcast("start", { jobId: item.jobId, prompt: item.requestText });
626
+ let assistantSoFar = "";
627
+ const running = this.deps.runClaudeImpl({
628
+ cfg,
629
+ cwd: session.workspacePath,
630
+ prompt: item.requestText,
631
+ logger,
632
+ onTextDelta: (delta) => {
633
+ assistantSoFar += delta;
634
+ this.broadcast("delta", { jobId: item.jobId, delta });
635
+ }
636
+ });
637
+ this.active = { jobId: item.jobId, cancel: running.cancel };
638
+ let status = "succeeded";
639
+ let error = null;
640
+ let exitCode = null;
641
+ let stderr = "";
642
+ try {
643
+ const res = await running.promise;
644
+ exitCode = res.exitCode;
645
+ stderr = res.stderr;
646
+ if (res.timedOut) {
647
+ status = "timeout";
648
+ error = "timeout";
649
+ }
650
+ else if (res.canceled) {
651
+ status = "canceled";
652
+ error = "canceled";
653
+ }
654
+ else if (res.exitCode !== 0) {
655
+ status = "failed";
656
+ error = `exit_code=${res.exitCode}`;
657
+ }
658
+ if (!assistantSoFar)
659
+ assistantSoFar = res.assistantText;
660
+ }
661
+ catch (err) {
662
+ status = "failed";
663
+ error = err instanceof Error ? err.message : "unknown error";
664
+ }
665
+ finally {
666
+ this.active = undefined;
667
+ }
668
+ logger.info({ msg: "web chat job finished", status, exitCode });
669
+ if (stderr)
670
+ logger.debug({ msg: "claude stderr", stderr });
671
+ this.deps.storage.setJobFinished({ jobId: item.jobId, status, responseText: assistantSoFar, error, exitCode });
672
+ this.broadcast("done", { jobId: item.jobId, status, error });
673
+ this.sendStatus();
674
+ }
675
+ }
676
+ async function startWebServer(opts) {
677
+ // Local mutable config; we don't hot-reload, but UI should reflect last saved values.
678
+ let cfg = opts.config;
679
+ const deps = {
680
+ exit: opts.deps?.exit ?? ((code) => process.exit(code)),
681
+ telegramGetMe: opts.deps?.telegramGetMe ?? (async (token) => {
682
+ try {
683
+ const url = `https://api.telegram.org/bot${token}/getMe`;
684
+ const res = await fetch(url, { method: "GET" });
685
+ const json = (await res.json().catch(() => null));
686
+ if (!res.ok)
687
+ return { ok: false, error: `HTTP ${res.status}` };
688
+ if (!json || typeof json !== "object")
689
+ return { ok: false, error: "Invalid response" };
690
+ if (json.ok !== true)
691
+ return { ok: false, error: json.description ? String(json.description) : "Telegram API error" };
692
+ return { ok: true, result: json.result };
693
+ }
694
+ catch (err) {
695
+ return { ok: false, error: err instanceof Error ? err.message : "Network error" };
696
+ }
697
+ }),
698
+ runClaude: opts.deps?.runClaude ?? runner_1.runClaude
699
+ };
700
+ const adminPassword = (cfg.web?.admin_password ?? "").trim();
701
+ if (!adminPassword)
702
+ throw new Error("web.admin_password is required to start web UI");
703
+ const bind = (cfg.web?.bind ?? "0.0.0.0").trim() || "0.0.0.0";
704
+ const port = typeof cfg.web?.port === "number" && Number.isFinite(cfg.web.port) ? cfg.web.port : 17800;
705
+ const chat = new WebChat({
706
+ getConfig: () => cfg,
707
+ logger: opts.logger,
708
+ storage: opts.storage,
709
+ semaphore: opts.semaphore,
710
+ runClaudeImpl: deps.runClaude
711
+ });
712
+ const server = node_http_1.default.createServer(async (req, res) => {
713
+ try {
714
+ if (!isAuthorized(req, adminPassword))
715
+ return unauthorized(res);
716
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
717
+ const method = (req.method ?? "GET").toUpperCase();
718
+ if (method === "GET" && url.pathname === "/") {
719
+ return sendHtml(res, 200, renderIndexHtml());
720
+ }
721
+ if (method === "GET" && url.pathname === "/chat") {
722
+ return chat.handleChatPage(res);
723
+ }
724
+ if (method === "GET" && url.pathname === "/api/chat/stream") {
725
+ chat.handleStream(req, res);
726
+ return;
727
+ }
728
+ if (method === "POST" && url.pathname === "/api/chat/send") {
729
+ return await chat.handleSend(req, res);
730
+ }
731
+ if (method === "GET" && url.pathname === "/api/chat/status") {
732
+ return await chat.handleStatus(req, res);
733
+ }
734
+ if (method === "POST" && url.pathname === "/api/chat/cancel") {
735
+ return await chat.handleCancel(req, res);
736
+ }
737
+ if (method === "POST" && url.pathname === "/api/chat/new") {
738
+ return await chat.handleNew(req, res);
739
+ }
740
+ if (method === "POST" && url.pathname === "/api/chat/reset") {
741
+ return await chat.handleReset(req, res);
742
+ }
743
+ if (method === "GET" && url.pathname === "/api/settings") {
744
+ return sendJson(res, 200, {
745
+ telegramTokenPresent: Boolean(cfg.telegram.token && cfg.telegram.token.trim()),
746
+ telegramTokenMasked: maskSecret(cfg.telegram.token ?? ""),
747
+ telegramAdminUserIdsCsv: (cfg.telegram.admin_user_ids ?? []).join(","),
748
+ telegramAllowlistUserIdsCsv: (cfg.telegram.allowlist?.user_ids ?? []).join(","),
749
+ claudeBinary: cfg.claude.binary ?? "claude",
750
+ loggingLevel: cfg.logging.level ?? "info",
751
+ loggingJson: Boolean(cfg.logging.json)
752
+ });
753
+ }
754
+ if (method === "GET" && url.pathname === "/api/secrets/telegram-token") {
755
+ return sendJson(res, 200, { token: cfg.telegram.token ?? "" });
756
+ }
757
+ if (method === "POST" && url.pathname === "/api/settings") {
758
+ const body = await readJsonBody(req, 256 * 1024);
759
+ const b = typeof body === "object" && body !== null ? body : {};
760
+ const token = typeof b.telegramToken === "string" ? b.telegramToken.trim() : "";
761
+ if (!token)
762
+ return sendJson(res, 400, { error: "telegramToken is required" });
763
+ const adminIds = parseCsvNumbersBestEffort(b.telegramAdminUserIdsCsv);
764
+ const allowIds = parseCsvNumbersBestEffort(b.telegramAllowlistUserIdsCsv);
765
+ if (adminIds.length === 0 && allowIds.length === 0) {
766
+ return sendJson(res, 400, { error: "At least one admin user id (or allowlist user id) is required" });
767
+ }
768
+ const claudeBinary = typeof b.claudeBinary === "string" ? b.claudeBinary.trim() : "";
769
+ if (!claudeBinary)
770
+ return sendJson(res, 400, { error: "claudeBinary is required" });
771
+ const loggingLevel = typeof b.loggingLevel === "string" ? b.loggingLevel.trim() : "info";
772
+ const allowedLevels = new Set(["fatal", "error", "warn", "info", "debug", "trace"]);
773
+ if (!allowedLevels.has(loggingLevel))
774
+ return sendJson(res, 400, { error: "Invalid loggingLevel" });
775
+ const loggingJson = Boolean(b.loggingJson);
776
+ cfg = {
777
+ ...cfg,
778
+ telegram: { ...cfg.telegram, token, admin_user_ids: adminIds, allowlist: { user_ids: allowIds } },
779
+ claude: { ...cfg.claude, binary: claudeBinary },
780
+ logging: { ...cfg.logging, level: loggingLevel, json: loggingJson }
781
+ };
782
+ // Generate full YAML (overwrite; no comment preservation).
783
+ const raw = yaml_1.default.stringify(cfg);
784
+ writeYamlAtomic(opts.configPath, raw);
785
+ return sendJson(res, 200, { ok: true, message: "Saved. Restart the service to apply changes." });
786
+ }
787
+ if (method === "POST" && url.pathname === "/api/doctor") {
788
+ const checks = [
789
+ ...(0, doctor_1.checkConfig)(opts.configPath),
790
+ ...(0, doctor_1.checkDirs)({ dataDir: cfg.runtime.data_dir, workspacesDir: cfg.runtime.workspaces_dir, uploadsDir: cfg.runtime.uploads_dir }),
791
+ (0, doctor_1.checkSqlite)(cfg.runtime.data_dir),
792
+ (0, doctor_1.checkClaude)(cfg.claude.binary ?? "claude", process.env)
793
+ ];
794
+ const ok = checks.every((c) => c.ok);
795
+ return sendJson(res, 200, { ok, checks });
796
+ }
797
+ if (method === "POST" && url.pathname === "/api/telegram/check-token") {
798
+ const token = (cfg.telegram.token ?? "").trim();
799
+ if (!token)
800
+ return sendJson(res, 400, { ok: false, error: "telegram.token is empty" });
801
+ const out = await deps.telegramGetMe(token);
802
+ if (!out.ok)
803
+ return sendJson(res, 502, { ok: false, error: out.error ?? "Telegram API error" });
804
+ return sendJson(res, 200, { ok: true, result: out.result });
805
+ }
806
+ if (method === "POST" && url.pathname === "/api/restart") {
807
+ sendJson(res, 200, { ok: true, message: "Restarting..." });
808
+ // Allow the response to flush before exiting.
809
+ setTimeout(() => deps.exit(0), 250);
810
+ return;
811
+ }
812
+ sendJson(res, 404, { error: "Not found" });
813
+ }
814
+ catch (err) {
815
+ // Never include secrets in errors; keep generic.
816
+ const msg = err instanceof Error ? err.message : "Internal error";
817
+ opts.logger.error({ msg: "web request failed", err: msg });
818
+ sendJson(res, 500, { error: "Internal error" });
819
+ }
820
+ });
821
+ await new Promise((resolve, reject) => {
822
+ server.once("error", reject);
823
+ server.listen(port, bind, () => resolve());
824
+ });
825
+ const addr = server.address();
826
+ const baseUrl = typeof addr === "object" && addr ? `http://${bind}:${addr.port}` : `http://${bind}:${port}`;
827
+ opts.logger.info({ msg: "web ui started", bind, port });
828
+ return {
829
+ baseUrl,
830
+ close: async () => {
831
+ await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
832
+ }
833
+ };
834
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smart-tinker/kayla",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Telegram -> Claude Code CLI orchestrator (Z.AI backend configured in Claude Code).",
5
5
  "license": "MIT",
6
6
  "repository": {