shadok-ai 0.1.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 (66) hide show
  1. package/README.md +196 -0
  2. package/dist/args.d.ts +8 -0
  3. package/dist/args.js +17 -0
  4. package/dist/args.js.map +1 -0
  5. package/dist/channels.d.ts +61 -0
  6. package/dist/channels.js +169 -0
  7. package/dist/channels.js.map +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +100 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +31 -0
  12. package/dist/config.js +59 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/detect.d.ts +17 -0
  15. package/dist/detect.js +41 -0
  16. package/dist/detect.js.map +1 -0
  17. package/dist/extract.d.ts +55 -0
  18. package/dist/extract.js +269 -0
  19. package/dist/extract.js.map +1 -0
  20. package/dist/main.d.ts +2 -0
  21. package/dist/main.js +90 -0
  22. package/dist/main.js.map +1 -0
  23. package/dist/pace.d.ts +33 -0
  24. package/dist/pace.js +53 -0
  25. package/dist/pace.js.map +1 -0
  26. package/dist/retry.d.ts +15 -0
  27. package/dist/retry.js +40 -0
  28. package/dist/retry.js.map +1 -0
  29. package/dist/secrets.d.ts +21 -0
  30. package/dist/secrets.js +71 -0
  31. package/dist/secrets.js.map +1 -0
  32. package/dist/server.d.ts +1 -0
  33. package/dist/server.js +854 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/session.d.ts +74 -0
  36. package/dist/session.js +244 -0
  37. package/dist/session.js.map +1 -0
  38. package/dist/setup-prompt.d.ts +6 -0
  39. package/dist/setup-prompt.js +25 -0
  40. package/dist/setup-prompt.js.map +1 -0
  41. package/dist/supervisor.d.ts +55 -0
  42. package/dist/supervisor.js +61 -0
  43. package/dist/supervisor.js.map +1 -0
  44. package/dist/tail.d.ts +66 -0
  45. package/dist/tail.js +244 -0
  46. package/dist/tail.js.map +1 -0
  47. package/dist/telegram.d.ts +48 -0
  48. package/dist/telegram.js +565 -0
  49. package/dist/telegram.js.map +1 -0
  50. package/dist/tmux.d.ts +69 -0
  51. package/dist/tmux.js +267 -0
  52. package/dist/tmux.js.map +1 -0
  53. package/dist/update-flag.d.ts +13 -0
  54. package/dist/update-flag.js +36 -0
  55. package/dist/update-flag.js.map +1 -0
  56. package/dist/updater.d.ts +18 -0
  57. package/dist/updater.js +45 -0
  58. package/dist/updater.js.map +1 -0
  59. package/dist/usage.d.ts +23 -0
  60. package/dist/usage.js +125 -0
  61. package/dist/usage.js.map +1 -0
  62. package/dist/worktree.d.ts +63 -0
  63. package/dist/worktree.js +153 -0
  64. package/dist/worktree.js.map +1 -0
  65. package/package.json +44 -0
  66. package/public/index.html +2583 -0
package/dist/server.js ADDED
@@ -0,0 +1,854 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import express from "express";
7
+ import { WebSocketServer } from "ws";
8
+ import { detectDialog, findSessionId, listSessions, loadHistory, } from "./extract.js";
9
+ import { findTransientErrors, newTransientErrors, RETRY_DELAYS_MS } from "./retry.js";
10
+ import { screenShowsWork } from "./detect.js";
11
+ import { PtyPilot } from "./session.js";
12
+ import { TmuxPilot, tmuxAvailable, tmuxHasSession, tmuxPaneCwd } from "./tmux.js";
13
+ import { scanUsage, sessionFilePath, tailSession } from "./tail.js";
14
+ import { computePace, paceBlock, WINDOW_SEC } from "./pace.js";
15
+ import { getUsage } from "./usage.js";
16
+ import { loadChannels, loadGroups, saveGroups, upsertChannel, removeChannel, mergeClientChannels, } from "./channels.js";
17
+ import { startTelegram, renameTelegramTopic, closeTelegramTopic } from "./telegram.js";
18
+ import { migrateTgBindings } from "./channels.js";
19
+ import { secretsForCwd, secretKeys, setSecret, deleteSecret, resolveRepo } from "./secrets.js";
20
+ import { createWorktree, ensureWorktreeCheckout, gitDiff, isGitRepo, listPastSessions, } from "./worktree.js";
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const PORT = Number(process.env.PORT ?? 3789);
23
+ const app = express();
24
+ app.use(express.json({ limit: "1mb" }));
25
+ app.use(express.static(path.join(__dirname, "..", "public")));
26
+ // Markdown parser served locally (history rendering on the client).
27
+ app.get("/vendor/marked.js", (_req, res) => res.sendFile(path.join(__dirname, "..", "node_modules", "marked", "lib", "marked.umd.js")));
28
+ // Resumable sessions of a directory (for the "resume by id" picker).
29
+ app.get("/sessions", (req, res) => {
30
+ const cwd = String(req.query.cwd ?? "").trim() || process.cwd();
31
+ res.json(listSessions(cwd));
32
+ });
33
+ // Sessions alive in THIS server (agents spawned by any client, including the
34
+ // pilotctl thin client). They own no transcript until their first turn, so
35
+ // /sessions cannot see them — this is the only way the UI can list them.
36
+ app.get("/live", (_req, res) => {
37
+ res.json([...sessions.values()]
38
+ .filter((s) => !s.pilot.hasExited)
39
+ .map((s) => ({
40
+ id: s.id,
41
+ cwd: s.cwd,
42
+ branch: s.worktree?.branch ?? null,
43
+ busy: s.busy,
44
+ clients: s.clients.size,
45
+ lastPrompt: s.lastPrompt,
46
+ })));
47
+ });
48
+ // Current 5-hour and 7-day subscription usage, each window enriched with how it
49
+ // compares to the time already elapsed (for the quota gauges and the send guard).
50
+ app.get("/usage", async (_req, res) => {
51
+ const u = await getUsage();
52
+ const now = Date.now();
53
+ // The pace is derived per request, not per fetch: getUsage() caches for 60 s
54
+ // and a frozen pace would drift away from the clock.
55
+ const enrich = (w, durationSec) => w ? { ...w, ...computePace(w, durationSec, now) } : null;
56
+ res.json({
57
+ fiveHour: enrich(u?.fiveHour ?? null, WINDOW_SEC.fiveHour),
58
+ sevenDay: enrich(u?.sevenDay ?? null, WINDOW_SEC.sevenDay),
59
+ fetchedAt: u?.fetchedAt ?? now,
60
+ ...paceBlock(u, now),
61
+ });
62
+ });
63
+ // Changes made by a session (git status + diff), for the review panel.
64
+ app.get("/diff", (req, res) => {
65
+ const s = sessions.get(String(req.query.session ?? ""));
66
+ if (!s)
67
+ return res.json({ status: "", diff: "", branch: null, error: "no such session" });
68
+ res.json(gitDiff(s.cwd, s.worktree?.baseSha ?? null));
69
+ });
70
+ // Past worktree sessions of a repo (for reopening unfinished work).
71
+ app.get("/recover", (req, res) => {
72
+ const repo = String(req.query.repo ?? "").trim() || process.cwd();
73
+ res.json(isGitRepo(repo) ? listPastSessions(repo) : []);
74
+ });
75
+ // Server-side defaults (the launch directory pre-fills the working dir field).
76
+ app.get("/defaults", (_req, res) => {
77
+ res.json({ cwd: process.cwd() });
78
+ });
79
+ // Channel list, persisted server-side per launch directory — survives a wiped
80
+ // browser, another device, a restart or a reboot.
81
+ app.get("/channels", (_req, res) => res.json(loadChannels()));
82
+ app.put("/channels", (req, res) => {
83
+ // Merge, don't overwrite: the browser owns order + name/group, but must never
84
+ // drop a live or Telegram-bound session or strip server-owned fields.
85
+ const live = new Set([...sessions.values()].filter((s) => !s.pilot.hasExited).map((s) => s.id));
86
+ const before = new Map(loadChannels().map((c) => [c.sessionId, c.name]));
87
+ const merged = mergeClientChannels(Array.isArray(req.body) ? req.body : [], live);
88
+ // A web-side rename of a Telegram-bound channel → rename its topic too.
89
+ for (const c of merged) {
90
+ if (c.telegram?.threadId && c.name && c.name !== before.get(c.sessionId)) {
91
+ renameTelegramTopic(c.telegram.chatId, c.telegram.threadId, c.name);
92
+ }
93
+ }
94
+ res.json(merged);
95
+ });
96
+ app.get("/groups", (_req, res) => res.json(loadGroups()));
97
+ app.put("/groups", (req, res) => {
98
+ saveGroups(Array.isArray(req.body) ? req.body : []);
99
+ res.json({ ok: true });
100
+ });
101
+ // Per-repo secrets, injected as env into agents (stored 600 outside any repo).
102
+ // The repo is resolved from a session's cwd; values are NEVER returned.
103
+ function repoOf(session, repo) {
104
+ const s = sessions.get(String(session ?? ""));
105
+ return s ? resolveRepo(s.cwd) : String(repo ?? "").trim() || process.cwd();
106
+ }
107
+ app.get("/secrets", (req, res) => {
108
+ const repo = repoOf(req.query.session, req.query.repo);
109
+ res.json({ repo, keys: secretKeys(repo) });
110
+ });
111
+ app.put("/secrets", (req, res) => {
112
+ const { session, repo, key, value } = req.body ?? {};
113
+ if (typeof key !== "string" || !key.trim() || typeof value !== "string")
114
+ return res.status(400).json({ error: "key and value required" });
115
+ const r = repoOf(session, repo);
116
+ setSecret(r, key.trim(), value);
117
+ res.json({ repo: r, keys: secretKeys(r) });
118
+ });
119
+ app.delete("/secrets", (req, res) => {
120
+ const { session, repo, key } = req.body ?? {};
121
+ const r = repoOf(session, repo);
122
+ if (typeof key === "string")
123
+ deleteSecret(r, key);
124
+ res.json({ repo: r, keys: secretKeys(r) });
125
+ });
126
+ const server = http.createServer(app);
127
+ const wss = new WebSocketServer({ server, path: "/ws" });
128
+ /**
129
+ * Selects the transport. tmux is the DEFAULT whenever it is installed: the
130
+ * agent runs in a detached tmux session named after the Claude session id, so
131
+ * it survives the server restarting/crashing and is reattached on the next
132
+ * start of the same id. Set SHADOK_TMUX=0 to force the node-pty transport
133
+ * (which dies with the server). Falls back to node-pty if tmux is absent.
134
+ */
135
+ const USE_TMUX = process.env.SHADOK_TMUX !== "0" && tmuxAvailable();
136
+ function makePilot(id, cwd, args) {
137
+ // Inject the repo's secrets as env — never written into the working dir.
138
+ const env = secretsForCwd(cwd);
139
+ return USE_TMUX
140
+ ? new TmuxPilot({ cwd, args, env, tmuxName: "sk-" + id })
141
+ : new PtyPilot({ cwd, args, env });
142
+ }
143
+ /** Session-wide token totals, for the window-title counter. */
144
+ function tokenTotals(s) {
145
+ const t = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
146
+ for (const u of s.usage.values()) {
147
+ t.input += u.input;
148
+ t.output += u.output;
149
+ t.cacheCreation += u.cacheCreation;
150
+ t.cacheRead += u.cacheRead;
151
+ }
152
+ return t;
153
+ }
154
+ /**
155
+ * How long a session with no attached client is kept alive before being
156
+ * reclaimed. Closing a tab or reloading detaches but does NOT kill the agent;
157
+ * you reattach on return and the running turn continues. Set 0 to keep
158
+ * sessions until the process exits or an explicit End.
159
+ */
160
+ const IDLE_RECLAIM_MS = Number(process.env.SHADOK_IDLE_MIN ?? 60) * 60_000;
161
+ const sessions = new Map();
162
+ function broadcast(s, msg, except) {
163
+ const data = JSON.stringify(msg);
164
+ for (const c of s.clients) {
165
+ if (c !== except && c.readyState === c.OPEN)
166
+ c.send(data);
167
+ }
168
+ }
169
+ function destroySession(s) {
170
+ if (s.screenTimer)
171
+ clearInterval(s.screenTimer);
172
+ s.screenTimer = null;
173
+ if (s.idleTimer)
174
+ clearTimeout(s.idleTimer);
175
+ s.idleTimer = null;
176
+ if (s.retryTimer)
177
+ clearTimeout(s.retryTimer);
178
+ s.retryTimer = null;
179
+ s.stopTail?.();
180
+ s.stopTail = null;
181
+ s.pilot.kill();
182
+ // Worktrees are durable: never auto-removed. They persist (with their
183
+ // branch and any uncommitted changes) until an explicit merge/discard,
184
+ // so no work is ever silently lost.
185
+ sessions.delete(s.id);
186
+ }
187
+ function detach(ws, s) {
188
+ s.clients.delete(ws);
189
+ if (s.clients.size !== 0)
190
+ return;
191
+ // No viewer attached: keep the agent running and reclaim only after a long
192
+ // idle, so reloading or closing a tab never aborts work.
193
+ if (IDLE_RECLAIM_MS <= 0)
194
+ return;
195
+ if (s.idleTimer)
196
+ clearTimeout(s.idleTimer);
197
+ s.idleTimer = setTimeout(() => {
198
+ if (s.clients.size === 0)
199
+ destroySession(s);
200
+ }, IDLE_RECLAIM_MS);
201
+ }
202
+ async function createSession(id, cwd, args, worktree = null) {
203
+ const pilot = makePilot(id, cwd, args);
204
+ const s = {
205
+ id,
206
+ cwd,
207
+ pilot,
208
+ clients: new Set(),
209
+ busy: false,
210
+ lastPrompt: "",
211
+ screenTimer: null,
212
+ lastScreen: "",
213
+ contextPct: null,
214
+ stopTail: null,
215
+ worktree,
216
+ idleTimer: null,
217
+ // Resumed sessions start with what the transcript already consumed.
218
+ usage: scanUsage(sessionFilePath(cwd, id)),
219
+ retryTimer: null,
220
+ retryCount: 0,
221
+ errorsAtTurnStart: [],
222
+ turnStartedAt: null,
223
+ lastTurnMs: null,
224
+ };
225
+ await attachPilot(s);
226
+ return s;
227
+ }
228
+ /**
229
+ * Wires a session's pilot: exit handling, content tail, screen watcher, and the
230
+ * wait-until-up handshake. Split out of createSession so a restart can swap in a
231
+ * fresh pilot (e.g. to pick up new env/secrets) on the SAME Live object — every
232
+ * WS client keeps its reference, so nobody is disconnected.
233
+ */
234
+ async function attachPilot(s) {
235
+ const pilot = s.pilot;
236
+ const { id, cwd } = s;
237
+ s.pilotOff = pilot.onExit((code) => {
238
+ if (s.restarting)
239
+ return; // a restart is swapping the pilot; don't tear down
240
+ broadcast(s, { type: "exited", code });
241
+ destroySession(s);
242
+ });
243
+ pilot.start();
244
+ // Stream authoritative content from the session transcript: each assistant
245
+ // text/tool block is broadcast as soon as Claude Code writes it — complete,
246
+ // never truncated, at message granularity.
247
+ s.stopTail = tailSession(sessionFilePath(cwd, id), (e) => {
248
+ if (e.kind === "text")
249
+ broadcast(s, { type: "stream-text", text: e.text });
250
+ else if (e.kind === "tool")
251
+ broadcast(s, { type: "stream-tool", id: e.id, name: e.name, summary: e.summary });
252
+ else if (e.kind === "usage") {
253
+ s.usage.set(e.messageId, e.usage);
254
+ broadcast(s, { type: "tokens", tokens: tokenTotals(s) });
255
+ }
256
+ else
257
+ broadcast(s, {
258
+ type: "stream-result",
259
+ toolUseId: e.toolUseId,
260
+ text: e.text,
261
+ isError: e.isError,
262
+ });
263
+ });
264
+ let settled = false;
265
+ s.screenTimer = setInterval(() => {
266
+ if (pilot.hasExited)
267
+ return;
268
+ const scr = pilot.screen();
269
+ if (scr !== s.lastScreen) {
270
+ s.lastScreen = scr;
271
+ broadcast(s, { type: "screen", text: scr, working: pilot.isWorking() });
272
+ // Context-window usage from the TUI footer ("… ctx:37% …"), per session.
273
+ const m = scr.match(/ctx:\s*(\d+)\s*%/i);
274
+ const pct = m ? Number(m[1]) : s.contextPct;
275
+ if (pct !== s.contextPct) {
276
+ s.contextPct = pct;
277
+ broadcast(s, { type: "context", pct });
278
+ }
279
+ }
280
+ // Spontaneous resume: work restarting without a client prompt (e.g. a
281
+ // background agent completing and waking the model). No handler called
282
+ // finishTurn, so watch for it here and signal the turn like any other.
283
+ if (settled && !s.busy && pilot.isWorking())
284
+ finishTurn(s).catch(() => { });
285
+ }, 300);
286
+ // Ready as soon as the TUI is up: trust prompt, input line, or an
287
+ // in-flight turn. A session reattached MID-WORK (tmux survives server
288
+ // restarts, and turns can run for many minutes) must not block on idle —
289
+ // the screen watcher above signals the running turn right after `settled`.
290
+ const isUp = (scr) => /do you trust the files/i.test(scr) || screenShowsWork(scr) || scr.includes("❯");
291
+ let screen = await pilot.waitFor(isUp, { timeoutMs: 60_000 });
292
+ if (/do you trust the files/i.test(screen)) {
293
+ pilot.press("enter");
294
+ screen = await pilot.waitFor((scr) => screenShowsWork(scr) || scr.includes("❯"), { timeoutMs: 30_000 });
295
+ }
296
+ // Resuming a large session shows a "resume from summary?" prompt. Keep the
297
+ // FULL session as-is automatically (the machine may have slept mid-work; the
298
+ // user wants their full context back, not a summary), unless disabled.
299
+ if (process.env.SHADOK_RESUME_SUMMARY !== "1") {
300
+ const rd = detectDialog(screen);
301
+ const full = rd?.options.find((o) => /full session/i.test(o.label));
302
+ if (full && /resum|summary/i.test(rd.question)) {
303
+ await selectOption(pilot, full.n);
304
+ await pilot
305
+ .waitFor((scr) => screenShowsWork(scr) || scr.includes("❯"), { timeoutMs: 30_000 })
306
+ .catch(() => { });
307
+ }
308
+ }
309
+ settled = true;
310
+ sessions.set(id, s);
311
+ }
312
+ /**
313
+ * Waits for the current turn to finish and broadcasts the outcome. Content is
314
+ * streamed separately by the transcript tail; here we only signal an
315
+ * interactive dialog (turn stays suspended) or turn completion.
316
+ */
317
+ async function finishTurn(s) {
318
+ s.busy = true;
319
+ if (!s.turnStartedAt)
320
+ s.turnStartedAt = Date.now();
321
+ s.errorsAtTurnStart = findTransientErrors(s.pilot.screen());
322
+ broadcast(s, { type: "working", startedAt: s.turnStartedAt });
323
+ try {
324
+ await s.pilot.waitForIdle({ stableMs: 2000, timeoutMs: 900_000 });
325
+ const dialog = detectDialog(s.pilot.screen());
326
+ if (dialog)
327
+ broadcast(s, { type: "dialog", ...dialog });
328
+ else {
329
+ broadcast(s, { type: "turn-done", sessionId: s.id });
330
+ maybeScheduleRetry(s);
331
+ }
332
+ }
333
+ finally {
334
+ s.busy = false;
335
+ // Remember how long it took: a dialog suspends the turn and a completion
336
+ // ends it, but both freeze the client's timer, so both are worth keeping.
337
+ if (s.turnStartedAt)
338
+ s.lastTurnMs = Date.now() - s.turnStartedAt;
339
+ s.turnStartedAt = null;
340
+ }
341
+ }
342
+ /** The option number the ❯ cursor is currently on, or null. */
343
+ function selectedOptionN(screen) {
344
+ for (const l of screen.split("\n")) {
345
+ const m = l.match(/^\s*❯\s*(\d+)\.\s/);
346
+ if (m)
347
+ return Number(m[1]);
348
+ }
349
+ return null;
350
+ }
351
+ /**
352
+ * Selects option `n` in a single-select dialog by moving the ❯ cursor with
353
+ * arrow keys, then Enter — the only reliable way for preview-style dialogs
354
+ * that ignore digit keys. Falls back to typing the digit if the cursor can't
355
+ * be read.
356
+ */
357
+ async function selectOption(pilot, n) {
358
+ for (let i = 0; i < 12; i++) {
359
+ const cur = selectedOptionN(pilot.screen());
360
+ if (cur === null) {
361
+ pilot.write(String(n)); // fallback: older digit-selectable dialogs
362
+ return;
363
+ }
364
+ if (cur === n) {
365
+ pilot.press("enter");
366
+ return;
367
+ }
368
+ pilot.press(cur < n ? "down" : "up");
369
+ await new Promise((r) => setTimeout(r, 160));
370
+ }
371
+ pilot.press("enter");
372
+ }
373
+ /**
374
+ * Surfaces a dialog already on screen when a client connects — e.g. the
375
+ * "resume from summary" or permission prompt that appears at startup/resume.
376
+ * Without this, a resumed session waiting on such a dialog looks frozen.
377
+ */
378
+ function sendPendingDialog(s, send) {
379
+ const d = detectDialog(s.pilot.screen());
380
+ // The resume-from-summary prompt is auto-answered at startup; don't surface
381
+ // it (a stale copy can otherwise flash before the auto-answer lands).
382
+ if (d && d.options.some((o) => /full session/i.test(o.label)) && /resum|summary/i.test(d.question))
383
+ return;
384
+ if (d)
385
+ send({ type: "dialog", ...d });
386
+ }
387
+ /** Cancels a pending auto-retry (user took over, or session ends). */
388
+ function clearRetry(s, notify = false) {
389
+ if (!s.retryTimer)
390
+ return;
391
+ clearTimeout(s.retryTimer);
392
+ s.retryTimer = null;
393
+ if (notify)
394
+ broadcast(s, { type: "auto-retry-cancelled" });
395
+ }
396
+ /**
397
+ * Pas de re-test du rythme pendant une pause. Aligné sur le TTL du cache de
398
+ * usage.ts : la boucle d'attente n'émet aucune requête vers l'API.
399
+ */
400
+ const PACE_RECHECK_MS = 60_000;
401
+ /**
402
+ * If the turn died on a NEW transient API error (529 Overloaded, 5xx,
403
+ * timeout…), schedules an automatic `continue` — 15 s, then 30 s, then
404
+ * 60 s. Cancelled if the user takes over; gives up after 3 attempts.
405
+ */
406
+ function maybeScheduleRetry(s) {
407
+ if (s.retryTimer)
408
+ return; // one pending retry at a time
409
+ const fresh = newTransientErrors(s.errorsAtTurnStart, findTransientErrors(s.pilot.screen()));
410
+ if (fresh.length === 0) {
411
+ s.retryCount = 0; // clean turn: the error streak is over
412
+ return;
413
+ }
414
+ if (s.retryCount >= RETRY_DELAYS_MS.length) {
415
+ broadcast(s, { type: "auto-retry-gave-up", attempts: s.retryCount });
416
+ s.retryCount = 0;
417
+ return;
418
+ }
419
+ const delayMs = RETRY_DELAYS_MS[s.retryCount];
420
+ s.retryCount++;
421
+ broadcast(s, {
422
+ type: "auto-retry",
423
+ delayMs,
424
+ attempt: s.retryCount,
425
+ max: RETRY_DELAYS_MS.length,
426
+ });
427
+ // Set when the retry has been parked on a pace overrun, so the resume is
428
+ // announced only to clients that were told about the pause.
429
+ let held = false;
430
+ const fire = async () => {
431
+ // Keep s.retryTimer pointing at this chain across the await below: it is
432
+ // both the "a retry is pending" guard for maybeScheduleRetry and this
433
+ // chain's identity token.
434
+ const mine = s.retryTimer;
435
+ if (s.pilot.hasExited || s.busy) {
436
+ s.retryTimer = null;
437
+ return;
438
+ }
439
+ // Never forced: an automatic turn must not spend quota the user is being
440
+ // asked to hold back on. Park and re-test until the pace comes back down.
441
+ // A failed usage read must never block: paceBlock(null, …) reports
442
+ // "not blocked", so a rejection degrades to letting the retry through
443
+ // rather than wedging the chain on an unhandled rejection.
444
+ const verdict = paceBlock(await getUsage().catch(() => null), Date.now());
445
+ // A takeover (or teardown) replaced or cleared our timer while we were
446
+ // fetching: this chain is no longer the live one, so stand down — and
447
+ // leave s.retryTimer alone, it now belongs to whoever replaced us.
448
+ if (s.retryTimer !== mine)
449
+ return;
450
+ // Still ours, but the session died or a turn started on its own while we
451
+ // were fetching (the screen watcher sets s.busy without touching
452
+ // s.retryTimer). Submitting `continue` now would spend quota on an
453
+ // already-running turn. Release our own already-fired handle, otherwise
454
+ // maybeScheduleRetry's `if (s.retryTimer) return` guard stays wedged for
455
+ // this session until something else happens to call clearRetry.
456
+ if (s.pilot.hasExited || s.busy) {
457
+ s.retryTimer = null;
458
+ return;
459
+ }
460
+ if (verdict.blocked) {
461
+ if (!held) {
462
+ held = true;
463
+ broadcast(s, { type: "pace-hold", reason: verdict.reason });
464
+ }
465
+ s.retryTimer = setTimeout(fire, PACE_RECHECK_MS);
466
+ return;
467
+ }
468
+ s.retryTimer = null;
469
+ if (held)
470
+ broadcast(s, { type: "pace-resumed" });
471
+ broadcast(s, { type: "prompt-echo", text: "continue", auto: true });
472
+ s.busy = true;
473
+ s.turnStartedAt = Date.now();
474
+ broadcast(s, { type: "working", startedAt: s.turnStartedAt });
475
+ try {
476
+ await s.pilot.submit("continue");
477
+ }
478
+ catch {
479
+ return; // TUI unreachable: give the user back the controls
480
+ }
481
+ finally {
482
+ s.busy = false;
483
+ }
484
+ await finishTurn(s).catch(() => { });
485
+ };
486
+ s.retryTimer = setTimeout(fire, delayMs);
487
+ }
488
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
489
+ wss.on("connection", (ws) => {
490
+ let session = null;
491
+ const send = (msg) => {
492
+ if (ws.readyState === ws.OPEN)
493
+ ws.send(JSON.stringify(msg));
494
+ };
495
+ const fail = (message) => send({ type: "error", message });
496
+ ws.on("close", () => {
497
+ if (session)
498
+ detach(ws, session);
499
+ session = null;
500
+ });
501
+ ws.on("message", async (raw) => {
502
+ let msg;
503
+ try {
504
+ msg = JSON.parse(String(raw));
505
+ }
506
+ catch {
507
+ return fail("unreadable message");
508
+ }
509
+ try {
510
+ // Any user takeover cancels a pending auto-retry and ends the streak.
511
+ // "prompt" is settled inside its own case instead: a prompt refused on
512
+ // pace grounds sends nothing, so it must not count as a takeover — it
513
+ // would silently kill the pace pause it was just told about.
514
+ if (session &&
515
+ ["choose", "toggle", "freetext", "confirm", "key"].includes(msg.type)) {
516
+ clearRetry(session, true);
517
+ session.retryCount = 0;
518
+ }
519
+ switch (msg.type) {
520
+ case "start": {
521
+ if (session)
522
+ return fail("session already started");
523
+ const cwd = msg.cwd?.trim() || process.cwd();
524
+ // Deterministic id: enforced with --session-id for a new session,
525
+ // known for a resume. NEVER derive it from the most recent file
526
+ // in the directory — with several channels in the same directory
527
+ // they would all converge to the same session.
528
+ let id;
529
+ const args = [];
530
+ let resumed = false;
531
+ if (msg.resume) {
532
+ id = msg.resume;
533
+ args.push("--resume", id);
534
+ resumed = true;
535
+ }
536
+ else if (msg.continue) {
537
+ const found = findSessionId(cwd);
538
+ if (found) {
539
+ id = found;
540
+ args.push("--resume", id);
541
+ resumed = true;
542
+ }
543
+ else {
544
+ // Nothing to resume in this directory: new session.
545
+ id = randomUUID();
546
+ args.push("--session-id", id);
547
+ }
548
+ }
549
+ else {
550
+ id = randomUUID();
551
+ args.push("--session-id", id);
552
+ }
553
+ // Isolation: run a NEW session inside a fresh git worktree so the
554
+ // agent's edits stay contained until the user merges them.
555
+ let worktree = null;
556
+ let effectiveCwd = cwd;
557
+ // Resume: a reattached tmux agent knows its own cwd (e.g. a worktree
558
+ // path the client didn't supply — the Telegram bridge only passes the
559
+ // repo root). Trust the live pane so history and the transcript tail
560
+ // resolve to the right directory (invariant: cwd ↔ history).
561
+ if (resumed && USE_TMUX && tmuxHasSession("sk-" + id)) {
562
+ const paneCwd = tmuxPaneCwd("sk-" + id);
563
+ if (paneCwd && fs.existsSync(paneCwd))
564
+ effectiveCwd = paneCwd;
565
+ }
566
+ if (msg.worktree && !resumed && isGitRepo(cwd)) {
567
+ try {
568
+ worktree = createWorktree(cwd, id.slice(0, 8));
569
+ effectiveCwd = worktree.path;
570
+ }
571
+ catch (e) {
572
+ return fail("worktree creation failed: " + (e instanceof Error ? e.message : String(e)));
573
+ }
574
+ }
575
+ // Reopen: if resuming into a worktree whose checkout was reclaimed,
576
+ // recreate it from its branch so the past session can continue.
577
+ if (resumed && msg.branch && msg.repo && !fs.existsSync(effectiveCwd)) {
578
+ ensureWorktreeCheckout(msg.repo, msg.branch, effectiveCwd);
579
+ }
580
+ // Guard against a vanished directory (e.g. a restored channel whose
581
+ // worktree was removed): spawning claude there would exit instantly
582
+ // with a cryptic error. Signal it clearly so the client can drop it.
583
+ if (!fs.existsSync(effectiveCwd)) {
584
+ return send({
585
+ type: "gone",
586
+ sessionId: id,
587
+ message: "working directory no longer exists: " + effectiveCwd,
588
+ });
589
+ }
590
+ const existing = sessions.get(id);
591
+ if (existing && !existing.pilot.hasExited) {
592
+ // Session already piloted: attach to it (shared process). Cancel
593
+ // any pending reclaim — a viewer is back.
594
+ session = existing;
595
+ if (session.idleTimer) {
596
+ clearTimeout(session.idleTimer);
597
+ session.idleTimer = null;
598
+ }
599
+ session.clients.add(ws);
600
+ const turns = loadHistory(session.cwd, id);
601
+ if (turns.length)
602
+ send({ type: "history", turns });
603
+ send({
604
+ type: "ready",
605
+ sessionId: id,
606
+ cwd: session.cwd,
607
+ lastTurnMs: session.lastTurnMs,
608
+ });
609
+ upsertChannel({ sessionId: id, cwd: session.cwd, branch: session.worktree?.branch ?? null });
610
+ send({ type: "tokens", tokens: tokenTotals(session) });
611
+ if (session.contextPct !== null)
612
+ send({ type: "context", pct: session.contextPct });
613
+ send({
614
+ type: "screen",
615
+ text: session.pilot.screen(),
616
+ working: session.pilot.isWorking(),
617
+ });
618
+ if (session.busy)
619
+ send({ type: "working", startedAt: session.turnStartedAt });
620
+ sendPendingDialog(session, send);
621
+ break;
622
+ }
623
+ session = await createSession(id, effectiveCwd, args, worktree);
624
+ session.clients.add(ws);
625
+ if (resumed) {
626
+ const turns = loadHistory(effectiveCwd, id);
627
+ if (turns.length)
628
+ send({ type: "history", turns });
629
+ }
630
+ send({
631
+ type: "ready",
632
+ sessionId: id,
633
+ cwd: effectiveCwd,
634
+ branch: worktree?.branch ?? null,
635
+ });
636
+ upsertChannel({ sessionId: id, cwd: effectiveCwd, branch: worktree?.branch ?? null });
637
+ send({ type: "tokens", tokens: tokenTotals(session) });
638
+ if (session.contextPct !== null)
639
+ send({ type: "context", pct: session.contextPct });
640
+ sendPendingDialog(session, send);
641
+ break;
642
+ }
643
+ case "prompt": {
644
+ if (!session)
645
+ return fail("no session started");
646
+ if (session.busy)
647
+ return fail("a response is already in progress");
648
+ const text = msg.text.trim();
649
+ if (!text)
650
+ return;
651
+ // Above the ideal pace, a prompt needs an explicit second click. The
652
+ // check lives here because this is the single door every user prompt
653
+ // goes through — including the pilotctl thin client.
654
+ if (!msg.force) {
655
+ const verdict = paceBlock(await getUsage(), Date.now());
656
+ if (verdict.blocked)
657
+ return send({ type: "pace-blocked", reason: verdict.reason, text });
658
+ // The busy test above is now stale: a parked fire() (or another
659
+ // client's prompt) can have claimed the session while we awaited
660
+ // getUsage(). Submitting now would interleave two texts into one
661
+ // TUI. Mirrors the re-check fire() does after its own await.
662
+ if (session.busy)
663
+ return fail("a response is already in progress");
664
+ }
665
+ // Getting here means the prompt is really being sent — that is the
666
+ // takeover. A pending auto-retry (or pace pause) gives way to it.
667
+ clearRetry(session, true);
668
+ session.retryCount = 0;
669
+ session.lastPrompt = text;
670
+ // The session's other clients see the prompt arrive.
671
+ broadcast(session, { type: "prompt-echo", text }, ws);
672
+ session.busy = true;
673
+ session.turnStartedAt = Date.now();
674
+ broadcast(session, { type: "working", startedAt: session.turnStartedAt });
675
+ try {
676
+ await session.pilot.submit(text);
677
+ }
678
+ finally {
679
+ session.busy = false;
680
+ }
681
+ await finishTurn(session);
682
+ break;
683
+ }
684
+ case "choose": {
685
+ // Single select. Preview-style dialogs ("Enter to select · ↑/↓ to
686
+ // navigate") ignore digit keys, so navigate the ❯ cursor to the
687
+ // target option with arrows, then Enter (works for all variants).
688
+ if (!session)
689
+ return fail("no session started");
690
+ // You can only answer a dialog that's actually on screen. If it's not,
691
+ // the keyboard is stale (the session moved past it) or a turn is
692
+ // running — either way there's nothing to select here.
693
+ if (!detectDialog(session.pilot.screen()))
694
+ return fail(session.busy ? "a response is already in progress" : "this dialog is no longer active");
695
+ await selectOption(session.pilot, msg.n);
696
+ await sleep(500);
697
+ await finishTurn(session);
698
+ break;
699
+ }
700
+ case "toggle": {
701
+ // Multi-select: toggle the checkbox then rebroadcast the state.
702
+ if (!session)
703
+ return fail("no session started");
704
+ if (!detectDialog(session.pilot.screen()))
705
+ return fail(session.busy ? "a response is already in progress" : "this dialog is no longer active");
706
+ session.pilot.write(String(msg.n));
707
+ await sleep(500);
708
+ const d = detectDialog(session.pilot.screen());
709
+ if (d)
710
+ broadcast(session, { type: "dialog", ...d });
711
+ else
712
+ await finishTurn(session);
713
+ break;
714
+ }
715
+ case "freetext": {
716
+ // "Type something" option: digit → paste the text → Enter.
717
+ if (!session)
718
+ return fail("no session started");
719
+ if (!detectDialog(session.pilot.screen()))
720
+ return fail(session.busy ? "a response is already in progress" : "this dialog is no longer active");
721
+ const t = msg.text.trim();
722
+ if (!t)
723
+ return;
724
+ session.pilot.write(String(msg.n));
725
+ await sleep(700);
726
+ session.pilot.write(`\x1b[200~${t}\x1b[201~`);
727
+ await sleep(400);
728
+ session.pilot.press("enter");
729
+ await sleep(600);
730
+ const d = detectDialog(session.pilot.screen());
731
+ if (d)
732
+ broadcast(session, { type: "dialog", ...d });
733
+ else
734
+ await finishTurn(session);
735
+ break;
736
+ }
737
+ case "confirm": {
738
+ // Multi-select: Tab → "Submit answers" page → Enter.
739
+ if (!session)
740
+ return fail("no session started");
741
+ if (!detectDialog(session.pilot.screen()))
742
+ return fail(session.busy ? "a response is already in progress" : "this dialog is no longer active");
743
+ session.pilot.press("tab");
744
+ await sleep(600);
745
+ session.pilot.press("enter");
746
+ await sleep(400);
747
+ await finishTurn(session);
748
+ break;
749
+ }
750
+ case "key": {
751
+ // Manual keystroke from the terminal view (dialogs, menus…).
752
+ if (!session)
753
+ return fail("no session started");
754
+ const named = [
755
+ "enter",
756
+ "escape",
757
+ "up",
758
+ "down",
759
+ "left",
760
+ "right",
761
+ "tab",
762
+ "ctrl-c",
763
+ ];
764
+ if (named.includes(msg.key)) {
765
+ session.pilot.press(msg.key);
766
+ }
767
+ else if (msg.key.length === 1) {
768
+ session.pilot.write(msg.key);
769
+ }
770
+ break;
771
+ }
772
+ case "settle": {
773
+ // After a manual intervention: wait for the turn to finish.
774
+ if (!session || session.busy)
775
+ return;
776
+ await finishTurn(session);
777
+ break;
778
+ }
779
+ case "restart": {
780
+ // Re-spawn the agent in place, resuming the same session id, so it
781
+ // picks up fresh env (e.g. newly-added secrets). History is preserved
782
+ // (resume reads the transcript); every attached client keeps its ref.
783
+ if (!session)
784
+ return fail("no session started");
785
+ const s = session;
786
+ s.restarting = true;
787
+ s.pilotOff?.();
788
+ if (s.screenTimer)
789
+ clearInterval(s.screenTimer);
790
+ s.screenTimer = null;
791
+ s.stopTail?.();
792
+ s.stopTail = null;
793
+ if (s.retryTimer)
794
+ clearTimeout(s.retryTimer);
795
+ s.retryTimer = null;
796
+ // Clean /exit (not a hard kill) so claude releases the session lock
797
+ // and `--resume` works; then make sure the tmux session is gone.
798
+ await s.pilot.stop();
799
+ if (USE_TMUX) {
800
+ for (let i = 0; i < 30 && tmuxHasSession("sk-" + s.id); i++)
801
+ await sleep(100);
802
+ }
803
+ s.busy = false;
804
+ s.lastScreen = "";
805
+ broadcast(s, { type: "working", startedAt: Date.now() });
806
+ // Resume only if there's a transcript; a never-used session has
807
+ // nothing to resume (claude --resume would exit) — re-create it.
808
+ const hasTranscript = fs.existsSync(sessionFilePath(s.cwd, s.id));
809
+ s.pilot = makePilot(s.id, s.cwd, hasTranscript ? ["--resume", s.id] : ["--session-id", s.id]);
810
+ await attachPilot(s);
811
+ s.restarting = false;
812
+ broadcast(s, { type: "ready", sessionId: s.id, cwd: s.cwd, branch: s.worktree?.branch ?? null });
813
+ broadcast(s, { type: "screen", text: s.pilot.screen(), working: s.pilot.isWorking() });
814
+ break;
815
+ }
816
+ case "stop": {
817
+ // Explicit stop: ends the session for ALL clients, drops it from the
818
+ // one registry, and archives its Telegram topic if it had one.
819
+ if (!session)
820
+ return;
821
+ const s = session;
822
+ const ch = loadChannels().find((c) => c.sessionId === s.id);
823
+ // Update the registry BEFORE notifying, so a client that reacts to
824
+ // "stopped" (or the sync poll) sees it already gone.
825
+ removeChannel(s.id);
826
+ if (ch?.telegram?.threadId)
827
+ closeTelegramTopic(ch.telegram.chatId, ch.telegram.threadId);
828
+ broadcast(s, { type: "stopped" });
829
+ await s.pilot.stop();
830
+ destroySession(s);
831
+ session = null;
832
+ break;
833
+ }
834
+ }
835
+ }
836
+ catch (err) {
837
+ if (session)
838
+ session.busy = false;
839
+ fail(err instanceof Error ? err.message : String(err));
840
+ }
841
+ });
842
+ });
843
+ // Fold any legacy separate Telegram bindings into the one channel registry.
844
+ migrateTgBindings();
845
+ server.listen(PORT, () => {
846
+ console.log(`shadok-ai web: http://localhost:${PORT}`);
847
+ console.log(USE_TMUX
848
+ ? "transport: tmux (agents survive server restarts)"
849
+ : "transport: node-pty (agents die with the server; install tmux or unset SHADOK_TMUX=0 for durability)");
850
+ // Telegram control bridge — connects to this server's own /ws as a client
851
+ // (only if TELEGRAM_BOT_TOKEN is set), so Telegram shares the web sessions.
852
+ startTelegram(PORT);
853
+ });
854
+ //# sourceMappingURL=server.js.map