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.
- package/README.md +196 -0
- package/dist/args.d.ts +8 -0
- package/dist/args.js +17 -0
- package/dist/args.js.map +1 -0
- package/dist/channels.d.ts +61 -0
- package/dist/channels.js +169 -0
- package/dist/channels.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +100 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +59 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +17 -0
- package/dist/detect.js +41 -0
- package/dist/detect.js.map +1 -0
- package/dist/extract.d.ts +55 -0
- package/dist/extract.js +269 -0
- package/dist/extract.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +90 -0
- package/dist/main.js.map +1 -0
- package/dist/pace.d.ts +33 -0
- package/dist/pace.js +53 -0
- package/dist/pace.js.map +1 -0
- package/dist/retry.d.ts +15 -0
- package/dist/retry.js +40 -0
- package/dist/retry.js.map +1 -0
- package/dist/secrets.d.ts +21 -0
- package/dist/secrets.js +71 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +854 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +74 -0
- package/dist/session.js +244 -0
- package/dist/session.js.map +1 -0
- package/dist/setup-prompt.d.ts +6 -0
- package/dist/setup-prompt.js +25 -0
- package/dist/setup-prompt.js.map +1 -0
- package/dist/supervisor.d.ts +55 -0
- package/dist/supervisor.js +61 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/tail.d.ts +66 -0
- package/dist/tail.js +244 -0
- package/dist/tail.js.map +1 -0
- package/dist/telegram.d.ts +48 -0
- package/dist/telegram.js +565 -0
- package/dist/telegram.js.map +1 -0
- package/dist/tmux.d.ts +69 -0
- package/dist/tmux.js +267 -0
- package/dist/tmux.js.map +1 -0
- package/dist/update-flag.d.ts +13 -0
- package/dist/update-flag.js +36 -0
- package/dist/update-flag.js.map +1 -0
- package/dist/updater.d.ts +18 -0
- package/dist/updater.js +45 -0
- package/dist/updater.js.map +1 -0
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +125 -0
- package/dist/usage.js.map +1 -0
- package/dist/worktree.d.ts +63 -0
- package/dist/worktree.js +153 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +44 -0
- 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
|