shadok-ai 0.17.3 → 0.17.5

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"}
@@ -6,8 +6,16 @@
6
6
  * diff before connecting any GitHub account.
7
7
  */
8
8
  export declare const SELF_REPO_URL = "https://github.com/shadok-ai/shadok-ai.git";
9
- /** Our own copy, never the directory the running server was launched from. */
10
- export declare function selfRepoDir(): string;
9
+ /**
10
+ * Our own copy, never the directory the running server was launched from.
11
+ * `stateHome` defaults to the server's own home (mono-tenant / commons before
12
+ * migration), but is the DROPPED agent's home (`/home/<user>`) once agents run
13
+ * as a non-root OS user: the server's `/root` is mode 0700 and un-traversable by
14
+ * that user, so a clone there strands the Tweak agent — same reason as
15
+ * `worktreeDir` in `worktree.ts`. The caller makes the clone writable by the
16
+ * drop user; this only fixes WHERE it lands.
17
+ */
18
+ export declare function selfRepoDir(stateHome?: string): string;
11
19
  export type SelfRepoPlan = "clone" | "update" | "reclone";
12
20
  /** Pure: what to do with the checkout, given what is on disk. */
13
21
  export declare function selfRepoPlan(exists: boolean, isRepo: boolean): SelfRepoPlan;
@@ -26,4 +34,4 @@ export interface SelfRepoResult {
26
34
  * worktrees a tweak session owns are separate checkouts and are never touched —
27
35
  * refreshing under a live agent is how you lose its work.
28
36
  */
29
- export declare function ensureSelfRepo(): SelfRepoResult;
37
+ export declare function ensureSelfRepo(stateHome?: string): SelfRepoResult;
package/dist/selfrepo.js CHANGED
@@ -10,9 +10,17 @@ import path from "node:path";
10
10
  * diff before connecting any GitHub account.
11
11
  */
12
12
  export const SELF_REPO_URL = "https://github.com/shadok-ai/shadok-ai.git";
13
- /** Our own copy, never the directory the running server was launched from. */
14
- export function selfRepoDir() {
15
- return path.join(os.homedir(), ".shadok-ai", "self", "shadok-ai");
13
+ /**
14
+ * Our own copy, never the directory the running server was launched from.
15
+ * `stateHome` defaults to the server's own home (mono-tenant / commons before
16
+ * migration), but is the DROPPED agent's home (`/home/<user>`) once agents run
17
+ * as a non-root OS user: the server's `/root` is mode 0700 and un-traversable by
18
+ * that user, so a clone there strands the Tweak agent — same reason as
19
+ * `worktreeDir` in `worktree.ts`. The caller makes the clone writable by the
20
+ * drop user; this only fixes WHERE it lands.
21
+ */
22
+ export function selfRepoDir(stateHome = os.homedir()) {
23
+ return path.join(stateHome, ".shadok-ai", "self", "shadok-ai");
16
24
  }
17
25
  /** Pure: what to do with the checkout, given what is on disk. */
18
26
  export function selfRepoPlan(exists, isRepo) {
@@ -48,8 +56,8 @@ function git(args, cwd) {
48
56
  * worktrees a tweak session owns are separate checkouts and are never touched —
49
57
  * refreshing under a live agent is how you lose its work.
50
58
  */
51
- export function ensureSelfRepo() {
52
- const dir = selfRepoDir();
59
+ export function ensureSelfRepo(stateHome = os.homedir()) {
60
+ const dir = selfRepoDir(stateHome);
53
61
  const plan = selfRepoPlan(fs.existsSync(dir), fs.existsSync(path.join(dir, ".git")));
54
62
  try {
55
63
  if (plan === "reclone")
@@ -1 +1 @@
1
- {"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"selfrepo.js","sourceRoot":"","sources":["../src/selfrepo.ts"],"names":[],"mappings":"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"}
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";
@@ -1554,7 +1555,9 @@ app.get("/diff", (req, res) => {
1554
1555
  // Past worktree sessions of a repo (for reopening unfinished work).
1555
1556
  app.get("/recover", (req, res) => {
1556
1557
  const repo = String(req.query.repo ?? "").trim() || process.cwd();
1557
- const list = isGitRepo(repo) ? listPastSessions(repo) : [];
1558
+ const list = isGitRepo(repo)
1559
+ ? listPastSessions(repo, agentStateHome(commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE })))
1560
+ : [];
1558
1561
  // `repo` is a caller-supplied path: a caller who knows another group's HOME
1559
1562
  // could enumerate that group's past worktree sessions (branch/commit metadata +
1560
1563
  // first-prompt preview). Filter each past session to the caller's visible
@@ -1617,10 +1620,18 @@ app.put("/mute", (req, res) => {
1617
1620
  // Materialises shadok-ai's own source for the "Tweak Shadok-AI" CTA, and hands
1618
1621
  // back the directory to start the agent in. Sits here so it inherits the same
1619
1622
  // password gate as its neighbours.
1620
- app.post("/tweak/prepare", (_req, res) => {
1621
- const r = ensureSelfRepo();
1623
+ app.post("/tweak/prepare", async (_req, res) => {
1624
+ // The tweak agent runs as the commons drop user; its self-repo clone must live
1625
+ // in THAT user's home (reachable under 0700 /root) and be owned by it — same
1626
+ // reason as the worktree created off it. `commonsOsUser → null` in mono-tenant
1627
+ // makes `agentStateHome(null)` the server home and the chown a no-op, so
1628
+ // nothing changes there.
1629
+ const commonsUser = commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
1630
+ const r = ensureSelfRepo(agentStateHome(commonsUser));
1622
1631
  if (r.error)
1623
1632
  return res.status(500).json({ error: r.error });
1633
+ if (r.cwd)
1634
+ await chownAgentStateToDropUser(r.cwd, commonsUser);
1624
1635
  res.json({ cwd: r.cwd });
1625
1636
  });
1626
1637
  // Running version + latest seen on npm (null if not yet polled / check disabled).
@@ -2120,11 +2131,16 @@ app.post("/groups/members", (req, res) => {
2120
2131
  // DEPLOYMENT_MODE gate and the honest-failure rules (T3/T4) all live here. ──────
2121
2132
  /** Where a group's HOME is created (`SHADOK_HOME_BASE` override, else `/home`). */
2122
2133
  const GROUP_HOME_BASE = (process.env.SHADOK_HOME_BASE ?? "").trim() || DEFAULT_HOME_BASE;
2123
- /** The HOME whose `.claude/projects` holds a given agent's transcript: the drop
2124
- * user's own home when the agent is privilege-dropped (multi-tenant isolation),
2125
- * else the server's own home (commons / mono-tenant). The single answer the
2126
- * content tail and `loadHistory` must use so an isolated agent's reply streams. */
2127
- function agentTranscriptHome(user) {
2134
+ /** The HOME that holds ALL of an agent's on-disk state: its transcript
2135
+ * (`.claude/projects`, read by the content tail and `loadHistory`) AND its
2136
+ * worktrees + self-repo clone (`.shadok-ai/worktrees`, `.shadok-ai/self`). The
2137
+ * drop user's own home when the agent is privilege-dropped (multi-tenant
2138
+ * isolation), else the server's own home (commons / mono-tenant). The single
2139
+ * answer every one of those paths must use: the server's `/root` is mode 0700
2140
+ * and un-traversable by a dropped user, so state created there is silently
2141
+ * unreachable — a reply that never streams, or a Tweak agent wedged on Claude
2142
+ * Code's invalid-settings screen (its worktree lived under `/root`). */
2143
+ function agentStateHome(user) {
2128
2144
  return user ? homeForUser(user, GROUP_HOME_BASE) : os.homedir();
2129
2145
  }
2130
2146
  /** The COMMONS tier for secret resolution: Main plus every PUBLIC non-Main group.
@@ -2138,11 +2154,13 @@ function commonsGroupIds() {
2138
2154
  ids.push(g.id);
2139
2155
  return ids;
2140
2156
  }
2141
- /** The transcript HOME for a CHANNEL that may not be live (e.g. GET /download of
2142
- * a past session): a live session's own `transcriptHome` when we have it, else
2143
- * re-derived from the channel's group the same way a spawn's drop user is —
2144
- * the group's `sk-*` user, or the commons user when migrated, else the server. */
2145
- function channelTranscriptHome(chan) {
2157
+ /** The state HOME for a CHANNEL that may not be live (e.g. GET /download of a
2158
+ * past session, or pruning a worktree as the session ends): a live session's
2159
+ * own `transcriptHome` when we have it, else re-derived from the channel's
2160
+ * group the same way a spawn's drop user is — the group's `sk-*` user, or the
2161
+ * commons user when migrated, else the server. Governs both the transcript and
2162
+ * the worktree location, so prune finds exactly what the spawn created. */
2163
+ function channelStateHome(chan) {
2146
2164
  const live = sessions.get(chan.sessionId);
2147
2165
  if (live)
2148
2166
  return live.transcriptHome;
@@ -2150,7 +2168,7 @@ function channelTranscriptHome(chan) {
2150
2168
  return os.homedir();
2151
2169
  const g = chan.group ? loadGroups().find((x) => x.id === chan.group) : null;
2152
2170
  const user = (g ? osUserFor(g) : null) ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
2153
- return agentTranscriptHome(user);
2171
+ return agentStateHome(user);
2154
2172
  }
2155
2173
  /** Task 12: the one shared pot every group HOME links as `~/shared`. */
2156
2174
  const SHARED_POT_PATH = (process.env.SHADOK_SHARED_POT_PATH ?? "").trim() || DEFAULT_SHARED_POT_PATH;
@@ -2171,6 +2189,43 @@ function runPrivileged(cmd, argv) {
2171
2189
  p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`))));
2172
2190
  });
2173
2191
  }
2192
+ /**
2193
+ * Make a freshly-created worktree (or self-repo clone) usable by the DROP USER.
2194
+ * The server creates it as ROOT, so the checkout AND git's own worktree admin
2195
+ * dir (`<repo>/.git/worktrees/<name>`, where the agent's index/HEAD/logs live)
2196
+ * are root-owned — and a dropped agent could neither write them nor, before this
2197
+ * fix, even reach them under the 0700 server HOME. Now the checkout lives in the
2198
+ * drop user's OWN home (`agentStateHome` → `worktreeDir`), so only ownership is
2199
+ * left: chown BOTH the checkout and (for a worktree) its external admin dir to
2200
+ * the user. Best-effort and logged — a chown failure degrades to the old symptom
2201
+ * rather than failing the spawn, and the log names it. No-op with no drop user
2202
+ * (mono-tenant, or commons-as-root before migration): ownership is already right.
2203
+ */
2204
+ async function chownAgentStateToDropUser(checkout, user) {
2205
+ if (!user)
2206
+ return;
2207
+ const owner = `${user}:${user}`;
2208
+ try {
2209
+ await runPrivileged("chown", ["-R", owner, checkout]);
2210
+ // git's admin dir sits under the REPO's gitdir, not the checkout; resolve it
2211
+ // from inside the checkout so a worktree `.git` FILE is followed to it. Only
2212
+ // chown it when OUTSIDE the checkout (a worktree); for a plain clone it is
2213
+ // `<checkout>/.git`, already covered by the recursive chown above.
2214
+ let adminDir = "";
2215
+ try {
2216
+ adminDir = execFileSync("git", ["-C", checkout, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", timeout: 10_000 }).trim();
2217
+ }
2218
+ catch {
2219
+ /* not a git checkout: the recursive chown already covered it */
2220
+ }
2221
+ if (adminDir && !adminDir.startsWith(path.resolve(checkout) + path.sep)) {
2222
+ await runPrivileged("chown", ["-R", owner, adminDir]);
2223
+ }
2224
+ }
2225
+ catch (e) {
2226
+ console.warn(`state chown to ${user} failed for ${checkout} (agent may be unable to write it): ${e?.message ?? e}`);
2227
+ }
2228
+ }
2174
2229
  /** The shadok skills to install: [package source dir, installed name]. Mirrored
2175
2230
  * into the SERVER HOME by `seedXxxSkill` and, for multi-user isolation, into the
2176
2231
  * machine-wide ENTERPRISE tier by `seedSystemSkills`. */
@@ -4125,7 +4180,7 @@ app.get("/download", (req, res) => {
4125
4180
  if (!allowed) {
4126
4181
  let raw;
4127
4182
  try {
4128
- raw = fs.readFileSync(transcriptFilePath(chan.cwd, session, channelTranscriptHome(chan)), "utf8");
4183
+ raw = fs.readFileSync(transcriptFilePath(chan.cwd, session, channelStateHome(chan)), "utf8");
4129
4184
  }
4130
4185
  catch {
4131
4186
  return res.status(403).type("text").send("that file was not sent by this agent");
@@ -5162,6 +5217,12 @@ async function endChannel(sessionId) {
5162
5217
  const s = sessions.get(sessionId);
5163
5218
  const worktreeBranch = s?.worktree?.branch ?? ch?.branch;
5164
5219
  const repo = s?.worktree?.repo ?? ch?.repo;
5220
+ // Resolve the worktree's home WHILE the session is still live (its own
5221
+ // `transcriptHome` is the exact home the spawn created it in); fall back to the
5222
+ // channel's group-derived home once it is gone. Prune must look in the same
5223
+ // place createWorktree wrote, or a dropped agent's checkout under /home/<user>
5224
+ // would never be cleaned.
5225
+ const worktreeHome = s?.transcriptHome ?? (ch ? channelStateHome(ch) : os.homedir());
5165
5226
  if (s) {
5166
5227
  broadcast(s, { type: "stopped" });
5167
5228
  await s.pilot.stop().catch(() => { });
@@ -5171,7 +5232,7 @@ async function endChannel(sessionId) {
5171
5232
  // merged), keep it if there's uncommitted or unmerged work. Stops the empty-
5172
5233
  // worktree pile-up while never discarding real work.
5173
5234
  if (repo && worktreeBranch) {
5174
- const outcome = pruneWorktree(repo, worktreeBranch);
5235
+ const outcome = pruneWorktree(repo, worktreeBranch, worktreeHome);
5175
5236
  if (outcome !== "none")
5176
5237
  console.log(`worktree ${worktreeBranch}: ${outcome} on session end`);
5177
5238
  }
@@ -5270,8 +5331,8 @@ group = null) {
5270
5331
  const pilot = makePilot(id, cwd, args, profile, model, effort, user, group);
5271
5332
  // A dropped group agent writes its transcript under ITS OWN home, not the
5272
5333
  // server's — so the content tail / history must read from there (invariant:
5273
- // all content comes from the .jsonl). `agentTranscriptHome` is the one answer.
5274
- const transcriptHome = agentTranscriptHome(user);
5334
+ // all content comes from the .jsonl). `agentStateHome` is the one answer.
5335
+ const transcriptHome = agentStateHome(user);
5275
5336
  // Resumed sessions start with what the transcript already consumed.
5276
5337
  const seededUsage = scanUsage(sessionFilePath(cwd, id, transcriptHome));
5277
5338
  const backend = sessionBackend(profile, group);
@@ -6032,8 +6093,11 @@ wss.on("connection", (ws, req) => {
6032
6093
  // so a server updated underneath it refreshes the page automatically.
6033
6094
  send(versionMessage());
6034
6095
  ws.on("close", () => {
6096
+ // Detach from the CURRENT Live for this id, not the closure's possibly
6097
+ // retired one: a handover ([5d8d]) moved this ws onto the new Live, so
6098
+ // detaching the old object would leak the ws in the live one's client set.
6035
6099
  if (session)
6036
- detach(ws, session);
6100
+ detach(ws, sessions.get(session.id) ?? session);
6037
6101
  session = null;
6038
6102
  });
6039
6103
  ws.on("message", async (raw) => {
@@ -6045,6 +6109,16 @@ wss.on("connection", (ws, req) => {
6045
6109
  return fail("unreadable message");
6046
6110
  }
6047
6111
  try {
6112
+ // This connection can outlive the Live it attached to: a resume that
6113
+ // supersedes an exited-but-watched session (see `supersedeSession`,
6114
+ // [5d8d]) moved this ws onto the NEW Live, but this closure still points
6115
+ // at the retired one. Re-point it so every handler below — and the
6116
+ // takeover/retry logic — acts on the single real Live for this id.
6117
+ if (session) {
6118
+ const current = sessions.get(session.id);
6119
+ if (current && current !== session)
6120
+ session = current;
6121
+ }
6048
6122
  // Any user takeover cancels a pending auto-retry and ends the streak.
6049
6123
  // "prompt" is settled inside its own case instead: a prompt refused on
6050
6124
  // pace grounds sends nothing, so it must not count as a takeover — it
@@ -6176,9 +6250,11 @@ wss.on("connection", (ws, req) => {
6176
6250
  }
6177
6251
  return m ?? null;
6178
6252
  };
6253
+ // The drop user a NEW spawn will run as — same inputs as the isolation
6254
+ // resolution further down. Hoisted out of the MT-token guard because the
6255
+ // worktree below must land in (and be owned by) this same user's home.
6256
+ let earlyUser = null;
6179
6257
  if (!resumed) {
6180
- // A new spawn has no stored group; same inputs as the isolation
6181
- // resolution further down.
6182
6258
  const earlyIso = resolveSpawnIsolation({
6183
6259
  mode: DEPLOYMENT_MODE,
6184
6260
  resumed,
@@ -6187,15 +6263,22 @@ wss.on("connection", (ws, req) => {
6187
6263
  storedGroupId: null,
6188
6264
  requestedGroupId: typeof msg.group === "string" && msg.group.trim() ? msg.group.trim() : null,
6189
6265
  });
6190
- const earlyUser = earlyIso.user ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
6266
+ earlyUser = earlyIso.user ?? commonsOsUser({ migrated: groupsMigrated, mode: DEPLOYMENT_MODE });
6191
6267
  const earlyMsg = mtRefusal(earlyUser);
6192
6268
  if (earlyMsg)
6193
6269
  return fail(earlyMsg);
6194
6270
  }
6195
6271
  if (msg.worktree && !resumed && isGitRepo(cwd)) {
6196
6272
  try {
6197
- worktree = createWorktree(cwd, id.slice(0, 8));
6273
+ // Create the checkout under the DROP USER's own home (not the
6274
+ // server's `/root`, which that user cannot traverse) and hand it to
6275
+ // them, so a dropped agent — notably every Tweak agent — can
6276
+ // actually work in it instead of landing on the invalid-settings
6277
+ // screen. `agentStateHome(null)` is the server home, so mono-tenant
6278
+ // is byte-for-byte unchanged.
6279
+ worktree = createWorktree(cwd, id.slice(0, 8), agentStateHome(earlyUser));
6198
6280
  effectiveCwd = worktree.path;
6281
+ await chownAgentStateToDropUser(worktree.path, earlyUser);
6199
6282
  }
6200
6283
  catch (e) {
6201
6284
  return fail("worktree creation failed: " + (e instanceof Error ? e.message : String(e)));
@@ -6388,6 +6471,21 @@ wss.on("connection", (ws, req) => {
6388
6471
  if (mtMsg)
6389
6472
  return fail(mtMsg);
6390
6473
  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);
6474
+ // One Live per agent (shared-ledger [5d8d]). We only reach here when
6475
+ // there was no live session to attach to — but `existing` may be a
6476
+ // RETIRED Live whose pilot has exited while viewers are still
6477
+ // attached (a web tab, a Telegram bridge). `createSession` has just
6478
+ // replaced it in the registry; without a handover those viewers are
6479
+ // orphaned on the old object — still fed the agent's answers by its
6480
+ // lingering tail, but cut off from every broadcast on the new Live
6481
+ // (the other interfaces' prompt echoes, dialogs, working/turn-done).
6482
+ // Move them onto the new Live and quiet the old one — without killing
6483
+ // the agent, which the new Live may now be driving on the same pane.
6484
+ if (existing && existing !== session) {
6485
+ const moved = supersedeSession(existing, session);
6486
+ if (moved)
6487
+ console.log(`session ${id.slice(0, 8)}: superseded a retired Live, moved ${moved} viewer(s)`);
6488
+ }
6391
6489
  session.clients.add(ws);
6392
6490
  if (resumed) {
6393
6491
  const turns = historyWithOffers(loadHistory(effectiveCwd, id, session.transcriptHome), id);