shadok-ai 0.17.4 → 0.17.6

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.
@@ -0,0 +1,47 @@
1
+ import type { WebSocket } from "ws";
2
+ /**
3
+ * The subset of a `Live` that a handover reads and mutates. Kept structural so
4
+ * the server's full `Live` satisfies it with no cast, and a test can build a
5
+ * fake. It DELIBERATELY omits `pilot`, `cwd`, `id` and `transcriptHome`: the
6
+ * handover must never be able to kill the agent or clear the shared tail
7
+ * position, and leaving those fields out of the type makes that a compile-time
8
+ * guarantee rather than a comment someone can regress past.
9
+ */
10
+ export interface HandoverLive {
11
+ clients: Set<WebSocket>;
12
+ rawClients?: Set<WebSocket>;
13
+ screenTimer: ReturnType<typeof setInterval> | null;
14
+ idleTimer: ReturnType<typeof setTimeout> | null;
15
+ retryTimer: ReturnType<typeof setTimeout> | null;
16
+ stopTail: (() => void) | null;
17
+ rawOff?: (() => void) | null;
18
+ }
19
+ /**
20
+ * Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
21
+ * the old one WITHOUT touching the agent. Returns how many viewers moved.
22
+ *
23
+ * The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
24
+ * channel still has web/Telegram viewers attached, a resume takes the
25
+ * `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
26
+ * entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
27
+ * running on the same transcript, so those clients still see the agent's
28
+ * ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
29
+ * interfaces' prompt echoes (the reported symptom: Telegram messages invisible
30
+ * on the web until reload), dialogs, and working/turn-done. Two `Live` for one
31
+ * agent; "server code correct in isolation", the split is the cause.
32
+ *
33
+ * The cure is one `Live` per agent: hand the viewers over and tear the old one
34
+ * down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
35
+ * have adopted the very same tmux pane — a `hasExited` that latched on a single
36
+ * transient probe (invariants 23/25) leaves the pane alive — so killing the old
37
+ * pilot would kill the live agent, and clearing the shared tail position would
38
+ * make the new tail replay the whole transcript from byte 0. So we stop the old
39
+ * `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
40
+ * type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
41
+ *
42
+ * Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
43
+ * subscription is per-pilot and is rebuilt when the engine room is next opened.
44
+ * Moving the chat `clients` set is what the reported bug needs; the old raw pipe
45
+ * is simply turned off so the retired `Live` goes fully quiet.
46
+ */
47
+ export declare function supersedeSession(old: HandoverLive, next: HandoverLive): number;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
3
+ * the old one WITHOUT touching the agent. Returns how many viewers moved.
4
+ *
5
+ * The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
6
+ * channel still has web/Telegram viewers attached, a resume takes the
7
+ * `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
8
+ * entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
9
+ * running on the same transcript, so those clients still see the agent's
10
+ * ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
11
+ * interfaces' prompt echoes (the reported symptom: Telegram messages invisible
12
+ * on the web until reload), dialogs, and working/turn-done. Two `Live` for one
13
+ * agent; "server code correct in isolation", the split is the cause.
14
+ *
15
+ * The cure is one `Live` per agent: hand the viewers over and tear the old one
16
+ * down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
17
+ * have adopted the very same tmux pane — a `hasExited` that latched on a single
18
+ * transient probe (invariants 23/25) leaves the pane alive — so killing the old
19
+ * pilot would kill the live agent, and clearing the shared tail position would
20
+ * make the new tail replay the whole transcript from byte 0. So we stop the old
21
+ * `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
22
+ * type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
23
+ *
24
+ * Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
25
+ * subscription is per-pilot and is rebuilt when the engine room is next opened.
26
+ * Moving the chat `clients` set is what the reported bug needs; the old raw pipe
27
+ * is simply turned off so the retired `Live` goes fully quiet.
28
+ */
29
+ export function supersedeSession(old, next) {
30
+ let moved = 0;
31
+ for (const c of old.clients) {
32
+ next.clients.add(c);
33
+ moved++;
34
+ }
35
+ old.clients.clear();
36
+ // Quiet the retired Live: its own tail (so one transcript is not tailed
37
+ // twice — the "two tails share one pos file" side effect noted in [5d8d]),
38
+ // its timers, and its raw pipe. Never the pilot, never the tail position.
39
+ if (old.screenTimer) {
40
+ clearInterval(old.screenTimer);
41
+ old.screenTimer = null;
42
+ }
43
+ if (old.idleTimer) {
44
+ clearTimeout(old.idleTimer);
45
+ old.idleTimer = null;
46
+ }
47
+ if (old.retryTimer) {
48
+ clearTimeout(old.retryTimer);
49
+ old.retryTimer = null;
50
+ }
51
+ old.stopTail?.();
52
+ old.stopTail = null;
53
+ old.rawOff?.();
54
+ old.rawOff = null;
55
+ old.rawClients?.clear();
56
+ return moved;
57
+ }
58
+ //# sourceMappingURL=live-handover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-handover.js","sourceRoot":"","sources":["../src/live-handover.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAiB,EAAE,IAAkB;IACpE,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACpB,KAAK,EAAE,CAAC;IACV,CAAC;IACD,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IAEpB,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;QACpB,aAAa,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC/B,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC;IACzB,CAAC;IACD,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;QAClB,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC5B,GAAG,CAAC,SAAS,GAAG,IAAI,CAAC;IACvB,CAAC;IACD,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;QACnB,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC7B,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;IACjB,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC;IACpB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;IACf,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;IAClB,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,CAAC;IAExB,OAAO,KAAK,CAAC;AACf,CAAC"}
package/dist/server.js CHANGED
@@ -65,6 +65,7 @@ import { getProfile, profileArgs, permissionModeArgs, isPermissionMode, isEffort
65
65
  import { BOOTSTRAP_ADMIN, loadAccounts, verifyPassword, sessionSecret, signSession, readSessionFull, sessionAlive, saveAccounts, userWriteVerdict, newInvite, inviteVerdict, hashPassword, promptAuthor, signSessionKey, readSessionKey, signTalkHandle, readTalkHandle, } from "./accounts.js";
66
66
  import { admins2fa, authenticationOptions, enrollCodeVerdict, isInternalClient, keepsTwoAdminsVerdict, loginNext, newEnrollCode, publicPasskey, putChallenge, readPending, registrationOptions, resetVerdict, signPending, takeChallenge, twoFactorEnableVerdict, verifyAuthentication, verifyRegistration, PENDING_TTL_MS, } from "./twofactor.js";
67
67
  import { ensureSelfRepo } from "./selfrepo.js";
68
+ import { supersedeSession } from "./live-handover.js";
68
69
  import { createWorktree, pruneWorktree, ensureWorktreeCheckout, gitDiff, isGitRepo, listPastSessions, } from "./worktree.js";
69
70
  import { latestVersion, update } from "./updater.js";
70
71
  import { resolveChannel } from "./update-channel.js";
@@ -2234,6 +2235,12 @@ const SKILL_SOURCES = [
2234
2235
  ["reload-skill", "shadok-reload"],
2235
2236
  ["ledger-skill", "shadok-ledger"],
2236
2237
  ["agents-skill", "shadok-ai-agents"],
2238
+ // files-skill is ALSO seeded to the server HOME by seedFilesSkill; it must be
2239
+ // here too, or a privilege-dropped group agent (which reads only the machine-
2240
+ // wide tier, never the server's /root HOME) silently loses it — which is
2241
+ // exactly what bit a migrated instance. test/system-skills.test.ts pins that
2242
+ // every shipped context/*-skill is listed here.
2243
+ ["files-skill", "shadok-files"],
2237
2244
  ];
2238
2245
  /** Claude Code's machine-wide ("Enterprise"/managed) skills directory — read by
2239
2246
  * EVERY OS user regardless of HOME, with precedence over a personal
@@ -6092,8 +6099,11 @@ wss.on("connection", (ws, req) => {
6092
6099
  // so a server updated underneath it refreshes the page automatically.
6093
6100
  send(versionMessage());
6094
6101
  ws.on("close", () => {
6102
+ // Detach from the CURRENT Live for this id, not the closure's possibly
6103
+ // retired one: a handover ([5d8d]) moved this ws onto the new Live, so
6104
+ // detaching the old object would leak the ws in the live one's client set.
6095
6105
  if (session)
6096
- detach(ws, session);
6106
+ detach(ws, sessions.get(session.id) ?? session);
6097
6107
  session = null;
6098
6108
  });
6099
6109
  ws.on("message", async (raw) => {
@@ -6105,6 +6115,16 @@ wss.on("connection", (ws, req) => {
6105
6115
  return fail("unreadable message");
6106
6116
  }
6107
6117
  try {
6118
+ // This connection can outlive the Live it attached to: a resume that
6119
+ // supersedes an exited-but-watched session (see `supersedeSession`,
6120
+ // [5d8d]) moved this ws onto the NEW Live, but this closure still points
6121
+ // at the retired one. Re-point it so every handler below — and the
6122
+ // takeover/retry logic — acts on the single real Live for this id.
6123
+ if (session) {
6124
+ const current = sessions.get(session.id);
6125
+ if (current && current !== session)
6126
+ session = current;
6127
+ }
6108
6128
  // Any user takeover cancels a pending auto-retry and ends the streak.
6109
6129
  // "prompt" is settled inside its own case instead: a prompt refused on
6110
6130
  // pace grounds sends nothing, so it must not count as a takeover — it
@@ -6457,6 +6477,21 @@ wss.on("connection", (ws, req) => {
6457
6477
  if (mtMsg)
6458
6478
  return fail(mtMsg);
6459
6479
  session = await createSession(id, effectiveCwd, args, worktree, profile, model, effort, spawnActor({ account: me?.name, origin: msg.origin, peer: callerPeer, parent: parentAtStart, agent: callerAgent?.name }), dropUser, agentGroupId);
6480
+ // One Live per agent (shared-ledger [5d8d]). We only reach here when
6481
+ // there was no live session to attach to — but `existing` may be a
6482
+ // RETIRED Live whose pilot has exited while viewers are still
6483
+ // attached (a web tab, a Telegram bridge). `createSession` has just
6484
+ // replaced it in the registry; without a handover those viewers are
6485
+ // orphaned on the old object — still fed the agent's answers by its
6486
+ // lingering tail, but cut off from every broadcast on the new Live
6487
+ // (the other interfaces' prompt echoes, dialogs, working/turn-done).
6488
+ // Move them onto the new Live and quiet the old one — without killing
6489
+ // the agent, which the new Live may now be driving on the same pane.
6490
+ if (existing && existing !== session) {
6491
+ const moved = supersedeSession(existing, session);
6492
+ if (moved)
6493
+ console.log(`session ${id.slice(0, 8)}: superseded a retired Live, moved ${moved} viewer(s)`);
6494
+ }
6460
6495
  session.clients.add(ws);
6461
6496
  if (resumed) {
6462
6497
  const turns = historyWithOffers(loadHistory(effectiveCwd, id, session.transcriptHome), id);