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.
- package/dist/live-handover.d.ts +47 -0
- package/dist/live-handover.js +58 -0
- package/dist/live-handover.js.map +1 -0
- package/dist/server.js +36 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -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);
|