shadok-ai 0.17.38 → 0.17.39

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 CHANGED
@@ -368,8 +368,14 @@ Enter to skip; you can add it later from the web UI).
368
368
  a profile edit that bumped no version still does. A `↻` lights up on the agent
369
369
  (hover for the reasons) and the **version menu** shows *Reload N agents to
370
370
  apply changes*; a reload respawns each one in place, keeping its history.
371
- Agents started before a given update stay on the old context until reloaded —
372
- this is how you spot the ones still running it.
371
+ **You don't have to click it**: the cockpit applies it by itself as soon as the
372
+ agent has nothing to lose — no turn running, no question waiting, nothing in
373
+ its input box, no background shell or monitor, no child notification queued —
374
+ and has been quiet for two minutes (the ↻ tooltip then says so).
375
+ One agent at a time, no nudge afterwards, and at most once per 30 minutes per
376
+ agent so a change that does not stick cannot loop. A message you send while
377
+ the respawn runs waits a few seconds for it instead of being refused. Clicking
378
+ still reloads at once; `SHADOK_AUTO_RELOAD=0` turns the automatic part off.
373
379
  - **Phone layout** — the cockpit is a fixed chassis that never pans sideways,
374
380
  sized on the *visual* viewport so the composer stays clear of the browser's
375
381
  own bar and, when the keyboard opens, sits right above it (the instrument bar
@@ -995,6 +1001,8 @@ machine, which silently shifts every daily prompt on a server running in UTC.
995
1001
  | `SHADOK_ALLOWED_HOSTS` | host names the cockpit answers besides its own (DNS-rebinding guard); with a password, setting it makes the check strict. `*` disables it |
996
1002
  | `SHADOK_TMUX=0` | force the node-pty transport instead of tmux |
997
1003
  | `SHADOK_IDLE_MIN` | minutes with no client before a session is reclaimed (60) |
1004
+ | `SHADOK_AUTO_RELOAD=0` | don't apply a pending "reload to apply" by itself — leave it to the ↻ click |
1005
+ | `SHADOK_AUTO_RELOAD_IDLE_SEC` | how long an agent must be quiet before that automatic reload (120) |
998
1006
  | `SHADOK_PERMISSION_MODE` | mode new agents start in (default `acceptEdits`) |
999
1007
  | `SHADOK_AUTOUPDATE` | fallback only — the GUI setting wins once used |
1000
1008
  | `SHADOK_PILOT_PROMPT=0` | don't inject the cockpit system prompt |
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Auto-reload: apply a pending "reload to apply" by itself, at a moment that
3
+ * cuts nothing.
4
+ *
5
+ * The cockpit already KNOWS when a running agent is behind what a fresh spawn
6
+ * would give it (`stalenessReasons`: prompt/guardrails/skills/secrets changed,
7
+ * auth cookie past its week, Claude Code binary updated — plus a profile change
8
+ * stored "at next reload"). Until now it only painted a ↻ badge and waited for a
9
+ * human to click it, so a profile edit or a shadok update sat unapplied on every
10
+ * agent nobody happened to look at.
11
+ *
12
+ * A reload is a `--resume`: the conversation survives, but the PROCESS does not.
13
+ * Everything that lives only in that process dies with it — a turn in flight, a
14
+ * question on screen, text typed in the TUI's input box, a background shell or
15
+ * monitor, a parent notification waiting to be delivered. So the rule is not
16
+ * "reload when idle" but "reload when there is provably nothing to lose", and
17
+ * every condition below names one of those losses.
18
+ *
19
+ * The quiet window is the soft half: an agent that JUST finished is the one a
20
+ * human is most likely about to answer. Waiting a couple of minutes past the
21
+ * last activity keeps the reload out of a live exchange. (A prompt that arrives
22
+ * anyway while the respawn runs is held until it is ready — see the prompt
23
+ * handler — so the worst case is a few seconds of delay, never a lost message.)
24
+ *
25
+ * The backoff is the safety net: if the reasons survive a reload (a fingerprint
26
+ * that is not deterministic, a version probe that flaps), reloading again every
27
+ * two minutes would be a respawn loop. One automatic attempt per window, then
28
+ * the badge stays for a human to look at.
29
+ */
30
+ export interface AutoReloadInput {
31
+ /** Is the feature on for this instance? */
32
+ enabled: boolean;
33
+ /** How many reasons the agent is behind (staleness + a stored profile gap). */
34
+ pendingReasons: number;
35
+ /** A turn is running, or the server is driving one (`busy`). */
36
+ busy: boolean;
37
+ /** The pane itself shows work (spinner) — covers a turn nobody started from here. */
38
+ working: boolean;
39
+ /** A prompt is being typed into the TUI right now. */
40
+ submitting: boolean;
41
+ /** A restart is already swapping this agent's process. */
42
+ restarting: boolean;
43
+ /** The pilot's process has exited (nothing to reload; the resume path owns it). */
44
+ exited: boolean;
45
+ /** A question/permission dialog is on screen, waiting for an answer. */
46
+ dialogOpen: boolean;
47
+ /** What the TUI input box holds — text a respawn would discard. */
48
+ inputBox: string;
49
+ /** Background shells / monitors the pane reports running. */
50
+ shells: number;
51
+ monitors: number;
52
+ /** Child-agent notifications queued for this agent as a parent. */
53
+ inboxPending: number;
54
+ /** An auto-retry of a failed turn is scheduled. */
55
+ retryPending: boolean;
56
+ /** Milliseconds since the last thing that happened on this agent. */
57
+ idleMs: number;
58
+ /** How long it must have been quiet. */
59
+ quietMs: number;
60
+ /** Milliseconds since the last AUTOMATIC reload of this agent (Infinity if never). */
61
+ sinceLastAutoMs: number;
62
+ /** Minimum gap between two automatic reloads of the same agent. */
63
+ backoffMs: number;
64
+ }
65
+ export type AutoReloadVerdict = {
66
+ reload: true;
67
+ } | {
68
+ reload: false;
69
+ why: string;
70
+ };
71
+ /** Pure: may this agent be reloaded right now without cutting anything? */
72
+ export declare function autoReloadVerdict(i: AutoReloadInput): AutoReloadVerdict;
73
+ /** `SHADOK_AUTO_RELOAD=0` turns it off; anything else (or unset) leaves it on. */
74
+ export declare function autoReloadEnabled(env: string | undefined): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Auto-reload: apply a pending "reload to apply" by itself, at a moment that
3
+ * cuts nothing.
4
+ *
5
+ * The cockpit already KNOWS when a running agent is behind what a fresh spawn
6
+ * would give it (`stalenessReasons`: prompt/guardrails/skills/secrets changed,
7
+ * auth cookie past its week, Claude Code binary updated — plus a profile change
8
+ * stored "at next reload"). Until now it only painted a ↻ badge and waited for a
9
+ * human to click it, so a profile edit or a shadok update sat unapplied on every
10
+ * agent nobody happened to look at.
11
+ *
12
+ * A reload is a `--resume`: the conversation survives, but the PROCESS does not.
13
+ * Everything that lives only in that process dies with it — a turn in flight, a
14
+ * question on screen, text typed in the TUI's input box, a background shell or
15
+ * monitor, a parent notification waiting to be delivered. So the rule is not
16
+ * "reload when idle" but "reload when there is provably nothing to lose", and
17
+ * every condition below names one of those losses.
18
+ *
19
+ * The quiet window is the soft half: an agent that JUST finished is the one a
20
+ * human is most likely about to answer. Waiting a couple of minutes past the
21
+ * last activity keeps the reload out of a live exchange. (A prompt that arrives
22
+ * anyway while the respawn runs is held until it is ready — see the prompt
23
+ * handler — so the worst case is a few seconds of delay, never a lost message.)
24
+ *
25
+ * The backoff is the safety net: if the reasons survive a reload (a fingerprint
26
+ * that is not deterministic, a version probe that flaps), reloading again every
27
+ * two minutes would be a respawn loop. One automatic attempt per window, then
28
+ * the badge stays for a human to look at.
29
+ */
30
+ /** Pure: may this agent be reloaded right now without cutting anything? */
31
+ export function autoReloadVerdict(i) {
32
+ const no = (why) => ({ reload: false, why });
33
+ if (!i.enabled)
34
+ return no("disabled");
35
+ if (i.pendingReasons <= 0)
36
+ return no("up to date");
37
+ if (i.exited)
38
+ return no("process exited");
39
+ if (i.restarting)
40
+ return no("already restarting");
41
+ if (i.busy || i.working)
42
+ return no("working");
43
+ if (i.submitting)
44
+ return no("a prompt is being typed");
45
+ if (i.dialogOpen)
46
+ return no("waiting on a question");
47
+ if (i.inputBox.trim())
48
+ return no("text in the input box");
49
+ if (i.shells > 0 || i.monitors > 0)
50
+ return no("background task running");
51
+ if (i.inboxPending > 0)
52
+ return no("notifications queued");
53
+ if (i.retryPending)
54
+ return no("auto-retry scheduled");
55
+ if (i.idleMs < i.quietMs)
56
+ return no("recently active");
57
+ if (i.sinceLastAutoMs < i.backoffMs)
58
+ return no("auto-reloaded recently");
59
+ return { reload: true };
60
+ }
61
+ /** `SHADOK_AUTO_RELOAD=0` turns it off; anything else (or unset) leaves it on. */
62
+ export function autoReloadEnabled(env) {
63
+ return env === undefined || !/^(0|false|off|no)$/i.test(env.trim());
64
+ }
65
+ //# sourceMappingURL=auto-reload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-reload.js","sourceRoot":"","sources":["../src/auto-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAwCH,2EAA2E;AAC3E,MAAM,UAAU,iBAAiB,CAAC,CAAkB;IAClD,MAAM,EAAE,GAAG,CAAC,GAAW,EAAqB,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;IACxE,IAAI,CAAC,CAAC,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC,UAAU,CAAC,CAAC;IACtC,IAAI,CAAC,CAAC,cAAc,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC,YAAY,CAAC,CAAC;IACnD,IAAI,CAAC,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC,gBAAgB,CAAC,CAAC;IAC1C,IAAI,CAAC,CAAC,UAAU;QAAE,OAAO,EAAE,CAAC,oBAAoB,CAAC,CAAC;IAClD,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC,SAAS,CAAC,CAAC;IAC9C,IAAI,CAAC,CAAC,UAAU;QAAE,OAAO,EAAE,CAAC,yBAAyB,CAAC,CAAC;IACvD,IAAI,CAAC,CAAC,UAAU;QAAE,OAAO,EAAE,CAAC,uBAAuB,CAAC,CAAC;IACrD,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,CAAC,uBAAuB,CAAC,CAAC;IAC1D,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC,yBAAyB,CAAC,CAAC;IACzE,IAAI,CAAC,CAAC,YAAY,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC,sBAAsB,CAAC,CAAC;IAC1D,IAAI,CAAC,CAAC,YAAY;QAAE,OAAO,EAAE,CAAC,sBAAsB,CAAC,CAAC;IACtD,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC,iBAAiB,CAAC,CAAC;IACvD,IAAI,CAAC,CAAC,eAAe,GAAG,CAAC,CAAC,SAAS;QAAE,OAAO,EAAE,CAAC,wBAAwB,CAAC,CAAC;IACzE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AAC1B,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,iBAAiB,CAAC,GAAuB;IACvD,OAAO,GAAG,KAAK,SAAS,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC"}
package/dist/server.js CHANGED
@@ -20,7 +20,8 @@ import { OFFER_TOOL, filesFileFor, loadOffers, saveOffers, offerVerdict, withOff
20
20
  // the browser): one source for reading the in-flight text off the screen.
21
21
  import { extractLiveText } from "../public/live-text.js";
22
22
  import { findTransientErrors, newTransientErrors, RETRY_DELAYS_MS } from "./retry.js";
23
- import { backgroundTasks, screenShowsWork, moveToOption } from "./detect.js";
23
+ import { backgroundTasks, screenShowsWork, moveToOption, boxText } from "./detect.js";
24
+ import { autoReloadVerdict, autoReloadEnabled } from "./auto-reload.js";
24
25
  import { PtyPilot } from "./session.js";
25
26
  import { ensureClaudeHome, ensureProjectTrusted, getDefaultModel, setDefaultModel, parseClaudeVersion, seedPlanForCwds, settingsPlan, } from "./claude-home.js";
26
27
  import { authStatus, cancelLogin, invalidateAuthStatus, startLogin, submitLoginCode } from "./claude-auth.js";
@@ -6248,6 +6249,13 @@ function broadcast(s, msg, except) {
6248
6249
  * Shared by the `restart` message and by `set-profile` with `restart: true`.
6249
6250
  */
6250
6251
  async function restartSession(s, by = SYSTEM_SPAWN) {
6252
+ const done = restartSessionNow(s, by).finally(() => {
6253
+ s.lastActivityAt = Date.now();
6254
+ });
6255
+ s.restartDone = done.catch(() => { });
6256
+ return done;
6257
+ }
6258
+ async function restartSessionNow(s, by) {
6251
6259
  // Fail-closed group check BEFORE any teardown. A restart re-derives the OS
6252
6260
  // user from the channel's stored group; if that group no longer resolves,
6253
6261
  // `resolveSpawnIsolation` would derive a null user and respawn a private agent
@@ -6355,6 +6363,7 @@ async function restartSession(s, by = SYSTEM_SPAWN) {
6355
6363
  sessionId: s.id,
6356
6364
  preprompt: prepromptById.get(s.id) ?? [],
6357
6365
  stale: sessionStaleness(s),
6366
+ staleAuto: AUTO_RELOAD,
6358
6367
  cwd: s.cwd,
6359
6368
  branch: s.worktree?.branch ?? null,
6360
6369
  lastTurnMs: s.lastTurnMs,
@@ -6447,7 +6456,70 @@ function sessionStaleness(s, channels) {
6447
6456
  });
6448
6457
  }
6449
6458
  function staleMessage(s, channels) {
6450
- return { type: "stale", sessionId: s.id, reasons: sessionStaleness(s, channels) };
6459
+ // `auto`: the cockpit will apply it by itself once the agent is idle, so the
6460
+ // badge reads "applies when idle" rather than asking for a click.
6461
+ return { type: "stale", sessionId: s.id, reasons: sessionStaleness(s, channels), auto: AUTO_RELOAD };
6462
+ }
6463
+ // ── Auto-reload: apply a pending reload at a moment that cuts nothing ────────
6464
+ // See src/auto-reload.ts for why each condition exists. One agent at a time —
6465
+ // the same no-herd rule as /restart-all (concurrent `--resume` race the OAuth
6466
+ // refresh) — and never a nudge afterwards: the agent was idle, so it stays idle.
6467
+ const AUTO_RELOAD = autoReloadEnabled(process.env.SHADOK_AUTO_RELOAD);
6468
+ const AUTO_RELOAD_QUIET_MS = Number(process.env.SHADOK_AUTO_RELOAD_IDLE_SEC ?? 120) * 1000;
6469
+ const AUTO_RELOAD_BACKOFF_MS = 30 * 60_000;
6470
+ let autoReloadRunning = false;
6471
+ /** Why `s` is behind, counting a profile stored "at next reload" too. */
6472
+ function pendingReloadReasons(s, channels) {
6473
+ const out = sessionStaleness(s, channels).map((r) => r.kind);
6474
+ if ((s.appliedProfile ?? null) !== (s.profile ?? null))
6475
+ out.push("profile");
6476
+ return out;
6477
+ }
6478
+ async function autoReloadSweep() {
6479
+ if (!AUTO_RELOAD || autoReloadRunning)
6480
+ return;
6481
+ const channels = loadChannels();
6482
+ const now = Date.now();
6483
+ for (const s of sessions.values()) {
6484
+ const reasons = pendingReloadReasons(s, channels);
6485
+ if (!reasons.length)
6486
+ continue;
6487
+ const screen = s.pilot.hasExited ? "" : s.pilot.screen();
6488
+ const verdict = autoReloadVerdict({
6489
+ enabled: AUTO_RELOAD,
6490
+ pendingReasons: reasons.length,
6491
+ busy: s.busy,
6492
+ working: !s.pilot.hasExited && s.pilot.isWorking(),
6493
+ submitting: !!s.submitting,
6494
+ restarting: !!s.restarting,
6495
+ exited: s.pilot.hasExited,
6496
+ dialogOpen: s.lastDialogKey !== null || !!detectDialog(screen),
6497
+ inputBox: boxText(screen),
6498
+ shells: s.shells,
6499
+ monitors: s.monitors,
6500
+ inboxPending: parentInbox.get(s.id)?.length ?? 0,
6501
+ retryPending: s.retryTimer !== null,
6502
+ idleMs: now - Math.max(s.lastActivityAt ?? 0, s.lastContentAt ?? 0),
6503
+ quietMs: AUTO_RELOAD_QUIET_MS,
6504
+ sinceLastAutoMs: s.lastAutoReloadAt ? now - s.lastAutoReloadAt : Infinity,
6505
+ backoffMs: AUTO_RELOAD_BACKOFF_MS,
6506
+ });
6507
+ if (!verdict.reload)
6508
+ continue;
6509
+ autoReloadRunning = true;
6510
+ s.lastAutoReloadAt = now;
6511
+ console.log(`auto-reload: ${s.id.slice(0, 8)} idle — respawning to apply ${reasons.join(", ")}`);
6512
+ try {
6513
+ await restartSession(s, { actor: "auto-reload", via: "system" });
6514
+ }
6515
+ catch {
6516
+ /* logged by restartSession; the backoff keeps it from looping */
6517
+ }
6518
+ finally {
6519
+ autoReloadRunning = false;
6520
+ }
6521
+ return; // one per sweep: the next one waits for the next tick
6522
+ }
6451
6523
  }
6452
6524
  /** Broadcast the staleness reasons, but only when they changed. */
6453
6525
  function broadcastStaleness(s, channels) {
@@ -6650,6 +6722,9 @@ group = null) {
6650
6722
  monitors: 0,
6651
6723
  stopTail: null,
6652
6724
  recentTexts: [],
6725
+ // A new Live (a spawn, an adoption after a server restart) counts as
6726
+ // activity: the auto-reload waits a quiet window before touching it.
6727
+ lastActivityAt: Date.now(),
6653
6728
  lastTurnSilent: false,
6654
6729
  worktree,
6655
6730
  idleTimer: null,
@@ -7092,6 +7167,7 @@ async function finishTurn(s) {
7092
7167
  finally {
7093
7168
  s.busy = false;
7094
7169
  s.interruptedAt = null;
7170
+ s.lastActivityAt = Date.now();
7095
7171
  // Remember how long it took: a dialog suspends the turn and a completion
7096
7172
  // ends it, but both freeze the client's timer, so both are worth keeping.
7097
7173
  if (s.turnStartedAt)
@@ -7430,6 +7506,8 @@ wss.on("connection", (ws, req) => {
7430
7506
  const current = sessions.get(session.id);
7431
7507
  if (current && current !== session)
7432
7508
  session = current;
7509
+ // Someone is interacting: hold the auto-reload's quiet window open.
7510
+ session.lastActivityAt = Date.now();
7433
7511
  }
7434
7512
  // Any user takeover cancels a pending auto-retry and ends the streak.
7435
7513
  // "prompt" is settled inside its own case instead: a prompt refused on
@@ -7663,6 +7741,7 @@ wss.on("connection", (ws, req) => {
7663
7741
  sessionId: id,
7664
7742
  preprompt: prepromptById.get(id) ?? [],
7665
7743
  stale: sessionStaleness(session),
7744
+ staleAuto: AUTO_RELOAD,
7666
7745
  cwd: session.cwd,
7667
7746
  lastTurnMs: session.lastTurnMs,
7668
7747
  });
@@ -7843,6 +7922,7 @@ wss.on("connection", (ws, req) => {
7843
7922
  sessionId: id,
7844
7923
  preprompt: prepromptById.get(id) ?? [],
7845
7924
  stale: sessionStaleness(session),
7925
+ staleAuto: AUTO_RELOAD,
7846
7926
  cwd: effectiveCwd,
7847
7927
  branch: worktree?.branch ?? null,
7848
7928
  });
@@ -7961,6 +8041,14 @@ wss.on("connection", (ws, req) => {
7961
8041
  if (session.busy)
7962
8042
  return fail("a response is already in progress", "busy");
7963
8043
  }
8044
+ // A respawn (an auto-reload, a reload click elsewhere) is swapping the
8045
+ // process: typing now would land in the one being torn down. Wait for
8046
+ // the new one — a few seconds — rather than refuse or lose the prompt.
8047
+ if (session.restarting) {
8048
+ await session.restartDone;
8049
+ if (session.busy)
8050
+ return fail("a response is already in progress", "busy");
8051
+ }
7964
8052
  // Getting here means the prompt is really being sent — that is the
7965
8053
  // takeover. A pending auto-retry (or pace pause) gives way to it.
7966
8054
  clearRetry(session, true);
@@ -8832,6 +8920,10 @@ server.listen(port, HOST, () => {
8832
8920
  broadcastStaleness(s, channels);
8833
8921
  }, 60_000);
8834
8922
  staleTimer.unref();
8923
+ // Auto-reload: twice a minute, respawn (at most) one idle agent that is
8924
+ // behind — the "reload to apply" badge applying itself. See autoReloadSweep.
8925
+ const autoReloadTimer = setInterval(() => void autoReloadSweep().catch(() => { }), 30_000);
8926
+ autoReloadTimer.unref();
8835
8927
  // The lead agent, if this instance has none. Deferred a beat so it dials a
8836
8928
  // server that is already accepting, and never awaited: a cockpit that starts
8837
8929
  // without its lead agent is a far smaller problem than a boot that hangs.