jeo-code 0.11.2 → 0.11.3

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.
@@ -15,6 +15,8 @@ import { readGlobalConfig } from "../state";
15
15
  export interface DaemonLockInfo {
16
16
  pid: number;
17
17
  startedAt: number;
18
+ purpose?: "daemon" | "pairing";
19
+ ready?: boolean;
18
20
  }
19
21
 
20
22
  export function isPidAlive(pid: number): boolean {
@@ -152,32 +154,87 @@ export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
152
154
  const raw = await fs.readFile(notifyDaemonLockPath(), "utf-8");
153
155
  const parsed = JSON.parse(raw) as Partial<DaemonLockInfo>;
154
156
  if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") return undefined;
155
- return { pid: parsed.pid, startedAt: parsed.startedAt };
157
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || !Number.isFinite(parsed.startedAt)) return undefined;
158
+ return { pid: parsed.pid, startedAt: parsed.startedAt, purpose: parsed.purpose, ready: parsed.ready };
156
159
  } catch {
157
160
  return undefined;
158
161
  }
159
162
  }
160
163
 
161
- /** Called by the daemon process itself on startup. Returns `undefined` when a
162
- * live owner already holds the lock (caller must exit without polling
163
- * Telegram); otherwise writes the lock and returns a `release()` closure. */
164
- export async function acquireDaemonLock(): Promise<{ release: () => Promise<void> } | undefined> {
164
+ async function interruptedRecoveryMessage(): Promise<string | undefined> {
165
+ const guard = `${notifyDaemonLockPath()}.reclaim`;
166
+ const stat = await fs.stat(guard).catch(() => undefined);
167
+ if (stat && Date.now() - stat.mtimeMs >= 30_000) {
168
+ return `Notification lock recovery is blocked by ${guard}. Stop all daemon/pairing processes and verify their owners are gone before manually removing this recovery guard; it is not automatically deleted because that can race a live lock owner.`;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /** Exclusive process lock shared with Telegram pairing, which also owns getUpdates. */
174
+ export async function acquireDaemonLock(purpose: "daemon" | "pairing" = "daemon"): Promise<{ release: () => Promise<void>; markReady: () => Promise<void> } | undefined> {
165
175
  await fs.mkdir(notifyDir(), { recursive: true, mode: 0o700 });
166
- const existing = await readDaemonLock();
167
- if (existing && existing.pid !== process.pid && (await isLockOwnerAlive(existing))) return undefined;
168
- const info: DaemonLockInfo = { pid: process.pid, startedAt: Date.now() };
169
- await fs.writeFile(notifyDaemonLockPath(), JSON.stringify(info), { mode: 0o600 });
176
+ const lockPath = notifyDaemonLockPath();
177
+ let contents = JSON.stringify({ pid: process.pid, startedAt: await processStartTimeMs(process.pid) ?? Date.now(), purpose, ready: false, owner: crypto.randomUUID() });
178
+ const create = async () => {
179
+ // Publish a fully written record atomically; a killed writer cannot leave partial JSON.
180
+ const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
181
+ try {
182
+ await fs.writeFile(tmp, contents, { mode: 0o600, flag: "wx" });
183
+ await fs.link(tmp, lockPath);
184
+ return true;
185
+ } catch (error) {
186
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
187
+ throw error;
188
+ } finally { await fs.unlink(tmp).catch(() => {}); }
189
+ };
190
+ if (!(await create())) {
191
+ // Serialize stale-owner reclamation so a second contender cannot unlink the winner.
192
+ let reclaim: fs.FileHandle;
193
+ try { reclaim = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
194
+ catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
196
+ const blocked = await interruptedRecoveryMessage();
197
+ if (blocked) throw new Error(blocked);
198
+ return undefined;
199
+ }
200
+ throw error;
201
+ }
202
+ try {
203
+ const existing = await readDaemonLock();
204
+ if (existing && await isLockOwnerAlive(existing)) return undefined;
205
+ // Recover legacy malformed locks only after a grace period for old writers.
206
+ if (!existing && Date.now() - (await fs.stat(lockPath)).mtimeMs < 30_000) return undefined;
207
+ await fs.unlink(lockPath);
208
+ if (!(await create())) return undefined;
209
+ } finally {
210
+ await reclaim.close();
211
+ await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
212
+ }
213
+ }
170
214
  return {
215
+ markReady: async () => {
216
+ if (await fs.readFile(lockPath, "utf-8").catch(() => "") !== contents) throw new Error("Notification daemon lost lock ownership during startup");
217
+ const next = JSON.stringify({ ...JSON.parse(contents), ready: true });
218
+ const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
219
+ try {
220
+ await fs.writeFile(tmp, next, { mode: 0o600, flag: "wx" });
221
+ await fs.rename(tmp, lockPath);
222
+ contents = next;
223
+ } finally { await fs.unlink(tmp).catch(() => {}); }
224
+ },
171
225
  release: async () => {
172
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
226
+ // An old release closure must not remove a newer owner's lock.
227
+ if (await fs.readFile(lockPath, "utf-8").catch(() => "") === contents) await fs.unlink(lockPath).catch(() => {});
173
228
  },
174
229
  };
175
230
  }
176
231
 
177
232
  export interface DaemonStatus {
178
- /** `notifications.enabled` + a stored bot token + chat id are all present. */
233
+ /** Master toggle and at least one complete, authorized transport. */
179
234
  configured: boolean;
180
235
  running: boolean;
236
+ pairing?: boolean;
237
+ ready?: boolean;
181
238
  /** A lock file exists but its pid is dead — a previous daemon crashed without cleanup. */
182
239
  stale: boolean;
183
240
  pid?: number;
@@ -190,7 +247,12 @@ export interface DaemonStatus {
190
247
 
191
248
  export async function isNotifyConfigured(): Promise<boolean> {
192
249
  const config = await readGlobalConfig();
193
- return Boolean(config.notifications?.enabled && config.notifications.telegram?.botToken && config.notifications.telegram?.chatId);
250
+ const n = config.notifications;
251
+ return Boolean(n?.enabled && (
252
+ (n.telegram?.botToken && n.telegram.chatId) ||
253
+ (n.discord?.botToken && n.discord.channelId && n.discord.allowedUserIds?.length) ||
254
+ (n.slack?.botToken && n.slack.appToken && n.slack.channelId && n.slack.allowedUserIds?.length)
255
+ ));
194
256
  }
195
257
 
196
258
  export async function daemonStatus(): Promise<DaemonStatus> {
@@ -199,7 +261,7 @@ export async function daemonStatus(): Promise<DaemonStatus> {
199
261
  const { alive, verified } = await inspectLockOwner(lock);
200
262
  // Surface the degraded case instead of hiding it: on a host with no `/proc` and no
201
263
  // usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
202
- return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
264
+ return { configured, running: alive && lock.purpose !== "pairing", pairing: alive && lock.purpose === "pairing", ready: alive && lock.ready === true, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
203
265
  }
204
266
 
205
267
  /** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
@@ -240,10 +302,11 @@ async function waitFor(predicate: () => Promise<boolean>, timeoutMs: number, ste
240
302
  return false;
241
303
  }
242
304
 
243
- export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; pid?: number; message: string }> {
305
+ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn, waitImpl: (predicate: () => Promise<boolean>, timeoutMs: number) => Promise<boolean> = waitFor): Promise<{ ok: boolean; pid?: number; message: string }> {
244
306
  const existing = await readDaemonLock();
245
307
  if (existing && (await isLockOwnerAlive(existing))) {
246
- return { ok: true, pid: existing.pid, message: `daemon already running (pid ${existing.pid})` };
308
+ if (existing.purpose === "pairing") return { ok: false, message: "Telegram pairing currently owns the notification lock; finish setup first." };
309
+ return { ok: existing.ready === true, pid: existing.pid, message: `daemon ${existing.ready !== true ? "is still initializing" : "already running"} (pid ${existing.pid}); connectivity not checked` };
247
310
  }
248
311
  // Check BEFORE spawning: an unconfigured daemon exits almost immediately (see
249
312
  // `runNotifyDaemonForeground`), which races the readiness poll below and used to
@@ -252,24 +315,45 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
252
315
  return { ok: false, message: "notifications not configured — run 'jeo notify setup' first." };
253
316
  }
254
317
 
318
+ const blocked = await interruptedRecoveryMessage();
319
+ if (blocked) return { ok: false, message: blocked };
320
+
255
321
  const cmd = daemonInvocation(process.argv[1], process.execPath, process.cwd());
256
322
  spawnImpl(cmd, process.cwd()).unref();
257
- const ready = await waitFor(async () => {
323
+ const ready = await waitImpl(async () => {
258
324
  const lock = await readDaemonLock();
259
- return Boolean(lock && isPidAlive(lock.pid));
260
- }, 2_000);
261
- if (!ready) return { ok: false, message: `daemon did not report ready within 2s — check ${notifyDaemonLogPath()}` };
325
+ return Boolean(lock && lock.purpose === "daemon" && lock.ready === true && isPidAlive(lock.pid));
326
+ }, 15_000);
327
+ if (!ready) return { ok: false, message: `daemon did not initialize within 15s — check ${notifyDaemonLogPath()}` };
262
328
  const lock = await readDaemonLock();
263
- return { ok: true, pid: lock?.pid, message: `daemon started (pid ${lock?.pid})` };
329
+ return { ok: true, pid: lock?.pid, message: `daemon initialized (pid ${lock?.pid}); use notify health to check platform access` };
330
+ }
331
+
332
+ async function removeStoppedLock(expected: DaemonLockInfo): Promise<void> {
333
+ const lockPath = notifyDaemonLockPath();
334
+ let guard: fs.FileHandle;
335
+ try { guard = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
336
+ catch (error) {
337
+ if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return;
338
+ throw error;
339
+ }
340
+ try {
341
+ const current = await readDaemonLock();
342
+ if (current?.pid === expected.pid && current.startedAt === expected.startedAt && !(await isLockOwnerAlive(current))) await fs.unlink(lockPath).catch(() => {});
343
+ } finally {
344
+ await guard.close();
345
+ await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
346
+ }
264
347
  }
265
348
 
266
349
  export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
267
350
  const lock = await readDaemonLock();
268
351
  const owner = lock ? await inspectLockOwner(lock) : undefined;
269
352
  if (!lock || !owner?.alive) {
270
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
353
+ if (lock) await removeStoppedLock(lock);
271
354
  return { ok: true, message: "daemon was not running" };
272
355
  }
356
+ if (lock.purpose === "pairing") return { ok: false, message: "Telegram pairing is active; cancel setup in its terminal first." };
273
357
  try {
274
358
  process.kill(lock.pid, "SIGTERM");
275
359
  } catch (err) {
@@ -277,13 +361,14 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
277
361
  }
278
362
  const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
279
363
  if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
280
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
364
+ await removeStoppedLock(lock);
281
365
  const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
282
366
  return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
283
367
  }
284
368
 
285
- export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
286
- const stopRes = await stopDaemon();
369
+ export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn, stopImpl: typeof stopDaemon = stopDaemon): Promise<{ ok: boolean; message: string }> {
370
+ const stopRes = await stopImpl();
371
+ if (!stopRes.ok) return stopRes;
287
372
  const startRes = await startDaemon(spawnImpl);
288
373
  return { ok: startRes.ok, message: `${stopRes.message}; ${startRes.message}` };
289
374
  }
@@ -0,0 +1,148 @@
1
+ import { version } from "../../../package.json";
2
+
3
+ /** Native Discord bot REST surface. Tokens are headers only; errors never include response bodies. */
4
+ export interface DiscordApiOptions {
5
+ fetchImpl?: typeof fetch;
6
+ now?: () => number;
7
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
8
+ requestTimeoutMs?: number;
9
+ maxRetries?: number;
10
+ }
11
+
12
+ export class DiscordApiError extends Error {
13
+ constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: { id: string }[] = []) {
14
+ super(message);
15
+ this.name = "DiscordApiError";
16
+ }
17
+ }
18
+
19
+ export interface DiscordGatewayInfo {
20
+ url: string;
21
+ session_start_limit: { remaining: number; reset_after: number; total: number; max_concurrency: number };
22
+ }
23
+
24
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
25
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
26
+ const abort = () => { clearTimeout(timer); reject(new DiscordApiError("Discord request stopped")); };
27
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
28
+ if (signal.aborted) abort();
29
+ else signal.addEventListener("abort", abort, { once: true });
30
+ return promise;
31
+ }
32
+
33
+ export class DiscordApi {
34
+ private readonly controller = new AbortController();
35
+ private cooldownUntil = 0;
36
+ private fatalStatus: number | undefined;
37
+ private queue = Promise.resolve();
38
+ private queued = 0;
39
+
40
+ constructor(private readonly token: string, private readonly options: DiscordApiOptions = {}) {}
41
+
42
+ stop(): void { this.controller.abort(); }
43
+
44
+ private request<T>(route: string, body?: unknown): Promise<T> {
45
+ if (this.queued >= 128) return Promise.reject(new DiscordApiError("Discord request queue full"));
46
+ this.queued++;
47
+ const result = this.queue.then(() => this.perform<T>(route, body));
48
+ this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
49
+ return result;
50
+ }
51
+
52
+ private async perform<T>(route: string, body?: unknown): Promise<T> {
53
+ const now = this.options.now ?? Date.now;
54
+ const retries = Math.max(0, Math.min(this.options.maxRetries ?? 2, 3));
55
+ for (let attempt = 0; attempt <= retries; attempt++) {
56
+ if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
57
+ if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
58
+ const wait = this.cooldownUntil - now();
59
+ if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
60
+ if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
61
+ if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
62
+ const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
63
+ let response: Response;
64
+ let data: unknown;
65
+ try {
66
+ response = await (this.options.fetchImpl ?? fetch)(`https://discord.com/api/v10${route}`, {
67
+ method: body === undefined ? "GET" : "POST",
68
+ headers: { Authorization: `Bot ${this.token}`, "Content-Type": "application/json", "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
69
+ body: body === undefined ? undefined : JSON.stringify(body),
70
+ signal: AbortSignal.any([this.controller.signal, timeout]),
71
+ redirect: "error",
72
+ });
73
+ if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
74
+ data = await response.json().catch(error => {
75
+ if (response.ok) throw error;
76
+ return null; // HTTP denial/cooldown headers remain authoritative without a JSON body.
77
+ });
78
+ } catch {
79
+ // Never retry an uncertain POST: Discord may already have accepted it.
80
+ if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
81
+ throw new DiscordApiError("Discord request failed or timed out");
82
+ }
83
+ const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
84
+ if (response.status === 429 || response.headers.get("X-RateLimit-Remaining") === "0") {
85
+ const raw = Number(response.status === 429
86
+ ? record.retry_after ?? response.headers.get("Retry-After")
87
+ : response.headers.get("X-RateLimit-Reset-After"));
88
+ const ms = Number.isFinite(raw) && raw > 0 ? raw * 1000 : 30_000;
89
+ // One serialized bot lane conservatively honors both route and global cooldowns.
90
+ this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
91
+ }
92
+ if (response.status === 429 && attempt < retries) continue;
93
+ if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, this.cooldownUntil - now()));
94
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new DiscordApiError("Invalid Discord response");
95
+ return data as T;
96
+ }
97
+ throw new DiscordApiError("Discord retry limit reached");
98
+ }
99
+
100
+ async getMe(): Promise<{ id: string; username: string; bot: boolean }> {
101
+ const me = await this.request<{ id: string; username: string; bot: boolean }>("/users/@me");
102
+ if (!/^\d+$/.test(me.id) || typeof me.id !== "string" || typeof me.username !== "string" || me.bot !== true) {
103
+ throw new DiscordApiError("Discord bot identity required");
104
+ }
105
+ return me;
106
+ }
107
+
108
+ async getChannel(channelId: string): Promise<{ id: string; type: number }> {
109
+ this.checkId(channelId);
110
+ const channel = await this.request<{ id: string; type: number }>(`/channels/${channelId}`);
111
+ if (channel.id !== channelId || !Number.isInteger(channel.type)) throw new DiscordApiError("Invalid Discord channel");
112
+ return channel;
113
+ }
114
+
115
+ async getGatewayBot(): Promise<DiscordGatewayInfo> {
116
+ const gateway = await this.request<DiscordGatewayInfo>("/gateway/bot");
117
+ const limit = gateway.session_start_limit;
118
+ if (typeof gateway.url !== "string" || !limit || !Number.isSafeInteger(limit.remaining) || limit.remaining < 0 ||
119
+ !Number.isFinite(limit.reset_after) || limit.reset_after < 0 || !Number.isSafeInteger(limit.total) || limit.total < 1 ||
120
+ !Number.isSafeInteger(limit.max_concurrency) || limit.max_concurrency < 1) throw new DiscordApiError("Invalid Discord gateway response");
121
+ return gateway;
122
+ }
123
+
124
+ async sendMessage(channelId: string, content: string): Promise<{ id: string }[]> {
125
+ this.checkId(channelId);
126
+ if (!content.trim()) return [];
127
+ if (content.length > 256_000) throw new DiscordApiError("Discord message exceeds relay limit");
128
+ const messages: { id: string }[] = [];
129
+ for (let offset = 0; offset < content.length;) {
130
+ let end = Math.min(offset + 2000, content.length);
131
+ if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!)) end--;
132
+ const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, {
133
+ content: content.slice(offset, end), allowed_mentions: { parse: [], replied_user: false },
134
+ }).catch(error => {
135
+ if (error instanceof DiscordApiError) throw new DiscordApiError(error.message, error.status, error.retryAfterMs, messages);
136
+ throw new DiscordApiError("Discord delivery failed", undefined, undefined, messages);
137
+ });
138
+ if (typeof message.id !== "string" || !/^\d+$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
139
+ messages.push(message);
140
+ offset = end;
141
+ }
142
+ return messages;
143
+ }
144
+
145
+ private checkId(id: string): void {
146
+ if (typeof id !== "string" || !/^\d{1,20}$/.test(id)) throw new DiscordApiError("Invalid Discord ID");
147
+ }
148
+ }