grok-telegram-bot 2.0.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.
Files changed (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Multi-account support for Grok Build. Grok has one active sign-in at a time
3
+ * (`~/.grok/auth.json`, written by `grok login`). This manager keeps several
4
+ * logins side by side and switches between them:
5
+ *
6
+ * • capture — snapshot the current auth.json as a named account,
7
+ * • switch — copy a saved snapshot back over auth.json (the caller restarts
8
+ * the agent so the new identity takes effect),
9
+ * • forget — drop a saved snapshot.
10
+ *
11
+ * Snapshots are copies of auth.json under `<dataDir>/accounts/` (git-ignored).
12
+ * The index stores only a label + token hash, never the token itself.
13
+ */
14
+ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { createLogger } from "../logger.js";
17
+ import { JsonStore } from "./json-store.js";
18
+ import { grokAuthPath, hasLogin, loginId, loginLabel } from "./grok-credentials.js";
19
+ import type { AccountInfo } from "./usage.js";
20
+
21
+ const log = createLogger("accounts");
22
+
23
+ /** Persisted, non-secret metadata about a saved account. */
24
+ export interface StoredAccount {
25
+ id: string;
26
+ label: string;
27
+ /** Hash of the sign-in token — the robust identity used to dedup/match. */
28
+ loginId?: string;
29
+ email?: string;
30
+ savedAt: string;
31
+ // Back-compat alias used by some callers.
32
+ startUrl?: string;
33
+ accountType?: string;
34
+ region?: string;
35
+ }
36
+
37
+ interface AccountsData {
38
+ accounts: StoredAccount[];
39
+ autoRotate?: boolean;
40
+ }
41
+
42
+ function makeId(): string {
43
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
44
+ }
45
+
46
+ export class AccountManager {
47
+ private readonly store: JsonStore<AccountsData>;
48
+ private readonly dir: string;
49
+
50
+ constructor(dataDir: string) {
51
+ this.dir = join(dataDir, "accounts");
52
+ this.store = new JsonStore<AccountsData>(join(this.dir, "index.json"), { accounts: [] });
53
+ }
54
+
55
+ list(): StoredAccount[] {
56
+ return [...this.store.get().accounts].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
57
+ }
58
+
59
+ autoRotateEnabled(): boolean {
60
+ return this.store.get().autoRotate === true;
61
+ }
62
+
63
+ setAutoRotate(on?: boolean): boolean {
64
+ const next = on ?? !this.autoRotateEnabled();
65
+ this.store.update((d) => {
66
+ d.autoRotate = next;
67
+ });
68
+ return next;
69
+ }
70
+
71
+ matchActive(key: string | undefined): StoredAccount | undefined {
72
+ if (!key) return undefined;
73
+ return this.store.get().accounts.find((a) => a.email === key || a.startUrl === key || a.loginId === key);
74
+ }
75
+
76
+ /** Id of the saved account matching the currently active sign-in, by token hash. */
77
+ activeAccountId(): string | undefined {
78
+ const lid = loginId();
79
+ if (!lid) return undefined;
80
+ return this.store.get().accounts.find((a) => a.loginId === lid)?.id;
81
+ }
82
+
83
+ get(id: string): StoredAccount | undefined {
84
+ return this.store.get().accounts.find((a) => a.id === id);
85
+ }
86
+
87
+ private snapshotPath(id: string): string {
88
+ return join(this.dir, `${id}.json`);
89
+ }
90
+
91
+ /**
92
+ * Snapshot the current sign-in (auth.json) as a saved account. Refreshes an
93
+ * existing account with the same token instead of duplicating. Throws when
94
+ * not signed in.
95
+ */
96
+ async captureCurrent(_info?: AccountInfo, customLabel?: string): Promise<StoredAccount> {
97
+ if (!hasLogin()) throw new Error("Not signed in — run /reauth (grok login) first.");
98
+ const lid = loginId();
99
+ if (!lid) throw new Error("No browser sign-in to save (an XAI_API_KEY-only login can't be snapshotted).");
100
+ await mkdir(this.dir, { recursive: true });
101
+ const label = customLabel?.trim() || loginLabel() || `account ${lid.slice(0, 6)}`;
102
+ const email = loginLabel();
103
+ const existing = this.store.get().accounts.find((a) => a.loginId === lid);
104
+ const id = existing?.id ?? makeId();
105
+ await copyFile(grokAuthPath(), this.snapshotPath(id));
106
+ const meta: StoredAccount = {
107
+ id,
108
+ label,
109
+ loginId: lid,
110
+ email,
111
+ startUrl: email,
112
+ savedAt: new Date().toISOString(),
113
+ };
114
+ this.store.update((d) => {
115
+ const idx = d.accounts.findIndex((a) => a.id === id);
116
+ if (idx >= 0) d.accounts[idx] = meta;
117
+ else d.accounts.push(meta);
118
+ });
119
+ log.info(`captured account ${meta.label} (${id})`);
120
+ return meta;
121
+ }
122
+
123
+ /**
124
+ * Make a saved account the active sign-in by copying its snapshot over
125
+ * auth.json. The caller restarts the ACP agent so the new identity takes
126
+ * effect. Throws when the snapshot is missing.
127
+ */
128
+ async switchTo(id: string): Promise<StoredAccount> {
129
+ const meta = this.get(id);
130
+ if (!meta) throw new Error("That account is no longer saved.");
131
+ const snap = this.snapshotPath(id);
132
+ const raw = await readFile(snap, "utf-8").catch(() => undefined);
133
+ if (!raw) throw new Error(`Saved login for ${meta.label} is missing — re-add it.`);
134
+ await mkdir(join(grokAuthPath(), ".."), { recursive: true });
135
+ await writeFile(grokAuthPath(), raw, "utf-8");
136
+ log.info(`switched active login to ${meta.label} (${id})`);
137
+ return meta;
138
+ }
139
+
140
+ rename(id: string, label: string): StoredAccount | undefined {
141
+ const clean = label.trim();
142
+ if (!clean) return this.get(id);
143
+ let updated: StoredAccount | undefined;
144
+ this.store.update((d) => {
145
+ const a = d.accounts.find((x) => x.id === id);
146
+ if (a) {
147
+ a.label = clean;
148
+ updated = a;
149
+ }
150
+ });
151
+ return updated;
152
+ }
153
+
154
+ async forget(id: string): Promise<boolean> {
155
+ const existed = !!this.get(id);
156
+ await rm(this.snapshotPath(id), { force: true }).catch(() => {});
157
+ this.store.update((d) => {
158
+ d.accounts = d.accounts.filter((a) => a.id !== id);
159
+ });
160
+ return existed;
161
+ }
162
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Grok authentication control for /reauth: `grok logout` then `grok login`.
3
+ * `grok login` performs the xAI account sign-in; on a bot host without a
4
+ * browser it prints a verification URL/code, which we stream back to Telegram.
5
+ */
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { createLogger } from "../logger.js";
9
+ import { authFileExists, hasLogin, loginLabel } from "./grok-credentials.js";
10
+
11
+ const run = promisify(execFile);
12
+ const log = createLogger("auth");
13
+
14
+ // eslint-disable-next-line no-control-regex
15
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
16
+
17
+ export interface LoginResult {
18
+ ok: boolean;
19
+ code: number | null;
20
+ cancelled?: boolean;
21
+ error?: string;
22
+ }
23
+
24
+ export interface LoginOptions {
25
+ onOutput: (text: string) => void;
26
+ timeoutMs?: number;
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ export class AuthService {
31
+ constructor(private readonly grokCliPath: string) {}
32
+
33
+ /** Run `grok logout` (non-interactive, best-effort). */
34
+ async logout(): Promise<{ ok: boolean; out: string }> {
35
+ try {
36
+ const { stdout, stderr } = await run(this.grokCliPath, ["logout"], { timeout: 30_000, encoding: "utf-8" });
37
+ return { ok: true, out: clean(`${stdout}${stderr}`) };
38
+ } catch (e) {
39
+ const err = e as { stdout?: string; stderr?: string; message?: string };
40
+ const out = clean(`${err.stdout ?? ""}${err.stderr ?? ""}`) || err.message || "logout failed";
41
+ return { ok: false, out };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Adopt an existing sign-in already present on this machine (a prior
47
+ * `grok login` wrote ~/.grok/auth.json). Returns ok:false with guidance when
48
+ * none is present.
49
+ */
50
+ async importExisting(): Promise<{ ok: boolean; error?: string; label?: string }> {
51
+ if (hasLogin()) return { ok: true, label: loginLabel() };
52
+ if (!authFileExists()) {
53
+ return {
54
+ ok: false,
55
+ error: "No Grok login found. Run `grok login` on the host (or set XAI_API_KEY), then try again.",
56
+ };
57
+ }
58
+ return { ok: false, error: "The Grok auth file has no usable token — run `grok login` again." };
59
+ }
60
+
61
+ /**
62
+ * Run `grok login`, streaming stdout/stderr (so any verification URL/code
63
+ * reaches the user). Resolves when the process exits, times out, or aborts.
64
+ */
65
+ login(opts: LoginOptions): Promise<LoginResult> {
66
+ const { onOutput, timeoutMs = 300_000, signal } = opts;
67
+ return new Promise<LoginResult>((resolve) => {
68
+ if (signal?.aborted) return resolve({ ok: false, code: null, cancelled: true });
69
+ let proc;
70
+ try {
71
+ proc = spawn(this.grokCliPath, ["login"], { stdio: ["ignore", "pipe", "pipe"] });
72
+ } catch (e) {
73
+ onOutput(`error: ${(e as Error).message}`);
74
+ return resolve({ ok: false, code: null });
75
+ }
76
+ let cancelled = false;
77
+ let settled = false;
78
+ let hardKill: NodeJS.Timeout | undefined;
79
+
80
+ const onAbort = (): void => {
81
+ cancelled = true;
82
+ try {
83
+ proc.kill();
84
+ } catch {
85
+ /* ignore */
86
+ }
87
+ hardKill = setTimeout(() => {
88
+ try {
89
+ proc.kill("SIGKILL");
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }, 2000);
94
+ };
95
+ const finish = (r: LoginResult): void => {
96
+ if (settled) return;
97
+ settled = true;
98
+ clearTimeout(timer);
99
+ if (hardKill) clearTimeout(hardKill);
100
+ signal?.removeEventListener("abort", onAbort);
101
+ resolve(r);
102
+ };
103
+ const feed = (b: Buffer): void => {
104
+ const t = clean(b.toString("utf-8"));
105
+ if (t) onOutput(t);
106
+ };
107
+ proc.stdout?.on("data", feed);
108
+ proc.stderr?.on("data", feed);
109
+ const timer = setTimeout(() => {
110
+ onOutput("\n\u23F1\uFE0F Timed out waiting for login to complete.");
111
+ try {
112
+ proc.kill();
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ }, timeoutMs);
117
+ signal?.addEventListener("abort", onAbort, { once: true });
118
+ proc.on("error", (e: Error) => {
119
+ onOutput(`error: ${e.message}`);
120
+ finish({ ok: false, code: null, cancelled });
121
+ });
122
+ proc.on("exit", (code: number | null) => {
123
+ // Success = clean exit AND a usable token now on disk.
124
+ finish({ ok: code === 0 && !cancelled && hasLogin(), code, cancelled });
125
+ });
126
+ });
127
+ }
128
+
129
+ isConfigured(): boolean {
130
+ return hasLogin();
131
+ }
132
+ }
133
+
134
+ function clean(s: string): string {
135
+ return s.replace(ANSI_RE, "").replace(/\r/g, "");
136
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Grok Build login state. The official CLI signs in with your xAI account
3
+ * (`grok login`, browser OIDC) and stores the token in `~/.grok/auth.json`:
4
+ *
5
+ * { "<scope_url>": { "key": "<token>" }, ... }
6
+ *
7
+ * (An `XAI_API_KEY` env var is an alternative for non-browser hosts.) This
8
+ * module reads that file so the bot can show who's signed in, detect a usable
9
+ * login, and snapshot/switch logins as named accounts (/accounts). The token is
10
+ * never transmitted anywhere.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ /** Guidance shown when no usable login is configured. */
18
+ export const UNSUPPORTED_LOGIN_HELP =
19
+ "Grok isn't signed in. Run `grok login` on the machine hosting the bot (or use " +
20
+ "/reauth), or set XAI_API_KEY. You need a SuperGrok or X Premium+ subscription.";
21
+
22
+ /** OIDC + legacy sign-in scope keys used in ~/.grok/auth.json. */
23
+ const OIDC_SCOPE = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828";
24
+ const LEGACY_SCOPE = "https://accounts.x.ai/sign-in";
25
+
26
+ export function grokConfigDir(): string {
27
+ return join(homedir(), ".grok");
28
+ }
29
+
30
+ /** Path to the Grok CLI auth token file (written by `grok login`). */
31
+ export function grokAuthPath(): string {
32
+ return join(grokConfigDir(), "auth.json");
33
+ }
34
+
35
+ export function authFileExists(): boolean {
36
+ return existsSync(grokAuthPath());
37
+ }
38
+
39
+ type AuthFile = Record<string, { key?: string } | undefined>;
40
+
41
+ export function readAuth(): AuthFile {
42
+ try {
43
+ return JSON.parse(readFileSync(grokAuthPath(), "utf-8")) as AuthFile;
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ /** The active sign-in token from auth.json (OIDC preferred, then legacy). */
50
+ export function currentToken(auth: AuthFile = readAuth()): string | undefined {
51
+ const oidc = auth[OIDC_SCOPE]?.key?.trim();
52
+ if (oidc) return oidc;
53
+ const legacy = auth[LEGACY_SCOPE]?.key?.trim();
54
+ return legacy || undefined;
55
+ }
56
+
57
+ /** Whether a usable login exists (a token in auth.json, or XAI_API_KEY). */
58
+ export function hasLogin(): boolean {
59
+ return !!currentToken() || !!process.env.XAI_API_KEY?.trim();
60
+ }
61
+
62
+ /** A stable, non-reversible id for the active login, for account dedup/match. */
63
+ export function loginId(auth: AuthFile = readAuth()): string | undefined {
64
+ const tok = currentToken(auth);
65
+ if (!tok) return undefined;
66
+ return createHash("sha256").update(tok).digest("hex").slice(0, 16);
67
+ }
68
+
69
+ export interface LoginIdentity {
70
+ email?: string;
71
+ name?: string;
72
+ }
73
+
74
+ function decodeJwtPayload(jwt: string): Record<string, unknown> | undefined {
75
+ const parts = jwt.split(".");
76
+ if (parts.length < 2) return undefined;
77
+ try {
78
+ return JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf-8")) as Record<string, unknown>;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Best-effort identity (email/name) decoded from the sign-in JWT. */
85
+ export function identityFromAuth(auth: AuthFile = readAuth()): LoginIdentity {
86
+ const tok = currentToken(auth);
87
+ if (!tok) return {};
88
+ const claims = decodeJwtPayload(tok);
89
+ if (!claims) return {};
90
+ const str = (k: string): string | undefined => (typeof claims[k] === "string" ? (claims[k] as string) : undefined);
91
+ const emailish = str("email") || str("preferred_username") || str("upn");
92
+ const email = emailish && emailish.includes("@") ? emailish : undefined;
93
+ return { email, name: str("name") || str("given_name") };
94
+ }
95
+
96
+ /** A short human label for the active login (email, else a short token hash). */
97
+ export function loginLabel(auth: AuthFile = readAuth()): string | undefined {
98
+ const id = identityFromAuth(auth);
99
+ if (id.email) return id.email;
100
+ if (id.name) return id.name;
101
+ const lid = loginId(auth);
102
+ return lid ? `account ${lid.slice(0, 6)}` : undefined;
103
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Single-instance guard, keyed per bot token (NOT per folder), so the same bot
3
+ * can't run twice no matter which directory it's started from.
4
+ *
5
+ * Telegram allows only ONE long-polling consumer per token — a second instance
6
+ * triggers 409 Conflict and, worse, a leftover "ghost" process started from an
7
+ * old folder keeps answering with a stale `.env` (e.g. an outdated
8
+ * `ALLOWED_USERS`, so you get "⛔ Not authorized"). On startup we therefore
9
+ * take an exclusive lock: if a still-alive instance holds it, we terminate that
10
+ * process (and its child tree on Windows) so the fresh process — with the
11
+ * current config — becomes the only consumer.
12
+ *
13
+ * The lock lives under the canonical home (`~/.grok/tg/locks/<tokenHash>.lock`)
14
+ * and stores only a pid + start time + whether the holder is supervised. The
15
+ * token itself is never written to disk (only its hash names the file).
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { createHash } from "node:crypto";
20
+ import { join } from "node:path";
21
+ import { createLogger } from "../logger.js";
22
+ import { killPid } from "../sessions/process.js";
23
+ import { isPidAlive } from "../sessions/store.js";
24
+
25
+ const log = createLogger("lock");
26
+
27
+ interface LockData {
28
+ pid: number;
29
+ startedAt: number;
30
+ /** True when the holder runs under a supervisor (systemd/launchd/Task). */
31
+ supervised: boolean;
32
+ }
33
+
34
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
35
+
36
+ export class InstanceLock {
37
+ private readonly file: string;
38
+ private held = false;
39
+
40
+ constructor(
41
+ token: string,
42
+ locksDir: string,
43
+ private readonly supervised: boolean,
44
+ ) {
45
+ const hash = createHash("sha256").update(token).digest("hex").slice(0, 16);
46
+ this.file = join(locksDir, `${hash}.lock`);
47
+ }
48
+
49
+ /**
50
+ * Become the sole instance for this token. Returns `false` (caller should
51
+ * exit) only when a *supervised* service instance is already running and this
52
+ * process is a plain manual start — we don't fight the background service
53
+ * (that would cause a restart/kill loop). Otherwise we take over: a live
54
+ * holder is terminated and the lock is rewritten with our pid.
55
+ */
56
+ async acquire(): Promise<boolean> {
57
+ const existing = this.read();
58
+ if (existing && existing.pid !== process.pid && isPidAlive(existing.pid)) {
59
+ if (existing.supervised && !this.supervised) {
60
+ log.warn(`a supervised service instance is already running (pid ${existing.pid}); not starting a duplicate`);
61
+ return false;
62
+ }
63
+ if (looksLikeNode(existing.pid)) {
64
+ log.warn(`another bot instance is running (pid ${existing.pid}); terminating it to take over`);
65
+ killPid(existing.pid);
66
+ for (let i = 0; i < 20 && isPidAlive(existing.pid); i++) await sleep(150); // up to ~3s
67
+ if (isPidAlive(existing.pid)) log.warn(`previous instance ${existing.pid} still alive after kill; continuing anyway`);
68
+ } else {
69
+ // The locked pid was recycled to an unrelated process — don't kill it,
70
+ // just reclaim the stale lock.
71
+ log.warn(`lock pid ${existing.pid} is not a node process; reclaiming stale lock`);
72
+ }
73
+ }
74
+ this.write();
75
+ this.held = true;
76
+ return true;
77
+ }
78
+
79
+ /** Release the lock if (and only if) we still own it. */
80
+ release(): void {
81
+ if (!this.held) return;
82
+ this.held = false;
83
+ try {
84
+ const cur = this.read();
85
+ if (cur?.pid === process.pid) rmSync(this.file, { force: true });
86
+ } catch {
87
+ /* best-effort */
88
+ }
89
+ }
90
+
91
+ private write(): void {
92
+ const data: LockData = { pid: process.pid, startedAt: Date.now(), supervised: this.supervised };
93
+ try {
94
+ mkdirSync(join(this.file, ".."), { recursive: true });
95
+ writeFileSync(this.file, JSON.stringify(data), "utf-8");
96
+ } catch (e) {
97
+ log.warn(`could not write lock file ${this.file}: ${(e as Error).message}`);
98
+ }
99
+ }
100
+
101
+ private read(): LockData | undefined {
102
+ try {
103
+ const d = JSON.parse(readFileSync(this.file, "utf-8")) as Partial<LockData>;
104
+ if (typeof d.pid === "number" && d.pid > 0) {
105
+ return { pid: d.pid, startedAt: Number(d.startedAt) || 0, supervised: Boolean(d.supervised) };
106
+ }
107
+ } catch {
108
+ /* no/invalid lock */
109
+ }
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Best-effort check that `pid` is a node process (our bot), to avoid killing an
116
+ * unrelated process that happened to reuse the pid. If the platform query can't
117
+ * run or be parsed, we assume it's ours (only this bot writes the lock) — better
118
+ * to clear a ghost than to leave one fighting over the token.
119
+ */
120
+ function looksLikeNode(pid: number): boolean {
121
+ try {
122
+ if (process.platform === "win32") {
123
+ const out = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
124
+ encoding: "utf-8",
125
+ stdio: ["ignore", "pipe", "ignore"],
126
+ });
127
+ // No matching task prints an INFO line, not a CSV row — treat as "gone".
128
+ if (!/^\s*"/.test(out)) return false;
129
+ return /node\.exe|tsx/i.test(out);
130
+ }
131
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
132
+ encoding: "utf-8",
133
+ stdio: ["ignore", "pipe", "ignore"],
134
+ });
135
+ return /node|tsx/i.test(out);
136
+ } catch {
137
+ return true;
138
+ }
139
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Minimal atomic JSON persistence. Reads on construction, writes atomically
3
+ * (temp file + rename) on save. No external dependencies.
4
+ */
5
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { createLogger } from "../logger.js";
8
+
9
+ const log = createLogger("json-store");
10
+
11
+ export class JsonStore<T> {
12
+ private data: T;
13
+
14
+ constructor(
15
+ private readonly path: string,
16
+ private readonly fallback: T,
17
+ ) {
18
+ this.data = this.read();
19
+ }
20
+
21
+ get(): T {
22
+ return this.data;
23
+ }
24
+
25
+ set(data: T): void {
26
+ this.data = data;
27
+ this.save();
28
+ }
29
+
30
+ /** Mutate via a callback, then persist. */
31
+ update(fn: (data: T) => void): void {
32
+ fn(this.data);
33
+ this.save();
34
+ }
35
+
36
+ private read(): T {
37
+ try {
38
+ return JSON.parse(readFileSync(this.path, "utf-8")) as T;
39
+ } catch {
40
+ return structuredClone(this.fallback);
41
+ }
42
+ }
43
+
44
+ private save(): void {
45
+ try {
46
+ mkdirSync(dirname(this.path), { recursive: true });
47
+ const tmp = `${this.path}.tmp`;
48
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf-8");
49
+ renameSync(tmp, this.path);
50
+ } catch (e) {
51
+ log.error(`failed to save ${this.path}:`, (e as Error).message);
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reasoning effort — a per-chat preference that steers how much deliberation
3
+ * the agent applies. Implemented as a concise directive prepended to prompts so
4
+ * it works regardless of backend-specific knobs.
5
+ */
6
+ import type { ReasoningEffort } from "./types.js";
7
+
8
+ const DIRECTIVE: Record<ReasoningEffort, string> = {
9
+ minimal: "Answer directly and briefly with minimal deliberation.",
10
+ low: "Keep reasoning light; prefer a quick, concise solution.",
11
+ medium: "", // default behaviour — no directive
12
+ high: "Think carefully and thoroughly before answering; verify your work.",
13
+ max: "Use maximum rigor: explore edge cases, double-check assumptions, and verify the result before finishing.",
14
+ };
15
+
16
+ const LABEL: Record<ReasoningEffort, string> = {
17
+ minimal: "Minimal",
18
+ low: "Low",
19
+ medium: "Medium",
20
+ high: "High",
21
+ max: "Max",
22
+ };
23
+
24
+ export function reasoningDirective(effort: ReasoningEffort): string {
25
+ return DIRECTIVE[effort] ?? "";
26
+ }
27
+
28
+ export function reasoningLabel(effort: ReasoningEffort): string {
29
+ return LABEL[effort] ?? effort;
30
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Per-chat settings persistence (project, agent, model, reasoning, pinned
3
+ * status message id). Backed by a single JSON file so state survives restarts.
4
+ */
5
+ import { join } from "node:path";
6
+ import { JsonStore } from "./json-store.js";
7
+ import { type ChatSettings, defaultSettings } from "./types.js";
8
+
9
+ type SettingsMap = Record<string, ChatSettings>;
10
+
11
+ export class SettingsStore {
12
+ private readonly store: JsonStore<SettingsMap>;
13
+
14
+ constructor(dataDir: string) {
15
+ this.store = new JsonStore<SettingsMap>(join(dataDir, "settings.json"), {});
16
+ }
17
+
18
+ get(chatId: number): ChatSettings {
19
+ const existing = this.store.get()[String(chatId)];
20
+ return existing ?? defaultSettings();
21
+ }
22
+
23
+ update(chatId: number, patch: Partial<ChatSettings>): ChatSettings {
24
+ const key = String(chatId);
25
+ const next = { ...this.get(chatId), ...patch };
26
+ this.store.update((m) => {
27
+ m[key] = next;
28
+ });
29
+ return next;
30
+ }
31
+
32
+ /** All chat ids that have interacted (for broadcast announcements). */
33
+ chatIds(): number[] {
34
+ return Object.keys(this.store.get())
35
+ .map(Number)
36
+ .filter((n) => Number.isFinite(n));
37
+ }
38
+ }