shadok-ai 0.2.45 → 0.2.46

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
@@ -15,9 +15,10 @@ npx shadok-ai
15
15
  Then open **http://localhost:3789**.
16
16
 
17
17
  **Prerequisites:** Node ≥ 20 and the [`claude`](https://claude.com/claude-code)
18
- CLI installed and signed in on the machine (shadok-ai drives your existing
19
- Claude Code, on your subscription). `tmux` is optional but recommendedwith
20
- it, agents survive the server restarting.
18
+ CLI installed (shadok-ai drives your existing Claude Code, on your
19
+ subscription). It does **not** need to be signed in beforehandif it isn't,
20
+ the cockpit offers the sign-in itself (see below). `tmux` is optional but
21
+ recommended — with it, agents survive the server restarting.
21
22
 
22
23
  On the first run it asks once for an optional **Telegram bot token** (press
23
24
  Enter to skip; you can add it later from the web UI).
@@ -156,6 +157,48 @@ location / {
156
157
  The client picks `wss://` on its own when the page is HTTPS, so there is nothing
157
158
  to configure on that side.
158
159
 
160
+ ### Signing in to Claude
161
+
162
+ A fresh machine — most often a fresh container — has two things in its way, and
163
+ shadok handles both so you never need a shell on the host.
164
+
165
+ **The first-run screens are pre-answered.** A virgin `claude` opens on a theme
166
+ picker, then on a per-directory trust dialog, and an agent started there never
167
+ reaches a prompt: no input box, every prompt fails, the tab reads "failed to
168
+ start". shadok seeds the answers in `~/.claude.json` — at boot for the global
169
+ ones, and before every spawn for the session's directory, because a worktree is
170
+ a brand-new directory and therefore a brand-new trust dialog. The seeding is
171
+ **purely additive**: a value already there is never overwritten, so on a machine
172
+ that has used Claude Code before, nothing is written at all.
173
+
174
+ **The sign-in is offered in the cockpit.** When the instance isn't signed in, a
175
+ card takes over the page with the OAuth link and a field for the code you get
176
+ back; agents refuse to start until it's done, so you can't accidentally
177
+ manufacture an agent with no credentials. The card exists only while it's
178
+ needed — there is no button that opens it when everything is fine.
179
+
180
+ **From Telegram**, which is where you usually are when a session signs out
181
+ mid-life:
182
+
183
+ ```
184
+ /login → replies with the link
185
+ /code <code> → finishes the sign-in (your message is deleted afterwards)
186
+ ```
187
+
188
+ Both are restricted to the bound board group or the owner's DM, like `/secret` —
189
+ an OAuth code grants access to the account. The sign-in is instance-global, so
190
+ the link is the same on both sides and a code pasted in Telegram also closes the
191
+ card open in a browser. When a spawn is refused for lack of credentials, shadok
192
+ posts **one** message to the board group; it won't repeat until the state flips
193
+ back.
194
+
195
+ **In Docker**, this replaces the old recreate ritual. `/root/.claude.json` is
196
+ not on a volume, so `docker rm` + recreate used to lose the onboarding state and
197
+ the respawned agents landed on the first-run screen — which meant restoring the
198
+ file *before* starting the container (`docker create` → `docker cp` →
199
+ `docker start`). That ordering is no longer necessary: shadok writes the file
200
+ itself, before any session spawns.
201
+
159
202
  ### SSH identity in Docker
160
203
 
161
204
  When shadok-ai runs **in a container** it gives itself an SSH key on first boot
@@ -364,14 +407,16 @@ client, or immediately on an explicit `stop` (which ends it for everyone).
364
407
  | `{type:"auto-retry"}` / `-cancelled` / `-gave-up` | transient API error being retried |
365
408
  | `{type:"version", …}` / `{type:"server-reload", version}` | update available / server updated, reload |
366
409
  | `{type:"term-data", data}` | **experimental** — raw pane output (base64) for a client-side terminal emulator |
367
- | `{type:"gone"}` / `{type:"error", message, code?}` / `{type:"exited", code}` / `{type:"stopped"}` | session lost, errors, termination. `error.code` is `"busy"` for a prompt refused mid-turn, so a machine client needn't match on the message text |
410
+ | `{type:"gone"}` / `{type:"error", message, code?}` / `{type:"exited", code}` / `{type:"stopped"}` | session lost, errors, termination. `error.code` is `"busy"` for a prompt refused mid-turn and `"logged-out"` for a spawn refused because the instance isn't signed in — so a machine client needn't match on the message text |
368
411
 
369
412
  HTTP endpoints (same auth): `/usage`, `/live`, `/sessions`, `/recover`,
370
413
  `/diff`, `/channels` (its GET adds a **derived** `crons` field — never stored),
371
414
  `/channel` (DELETE), `/groups`, `/crons`, `/timezone`, `/profiles`, `/secrets`,
372
415
  `/telegram`, `/defaults`, `/version`, `/autoupdate`, `/permission-mode`,
373
416
  `/tweak/prepare` (POST — clone/refresh shadok-ai's own source, returns the cwd
374
- to start the tweak agent in).
417
+ to start the tweak agent in), and the sign-in group: `/auth` (GET — `{loggedIn,
418
+ email?, subscriptionType?}`), `/auth/login` (POST — start a flow, returns
419
+ `{url}`; DELETE — cancel it), `/auth/code` (POST `{code}`).
375
420
 
376
421
  ## Library
377
422
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Claude Code authentication: status, and the interactive login.
3
+ *
4
+ * The load-bearing finding behind this module (verified 2026-08-08 against
5
+ * claude v2.1.226): `claude auth login --claudeai` needs NO PTY. Run with plain
6
+ * pipes it prints the OAuth URL on stdout and reads the code from stdin. So the
7
+ * login touches none of the screen heuristics — no `detectDialog`, no
8
+ * `screenShowsWork`, nothing from the fragile family invariant nº 2 warns
9
+ * about. It is a spawn, a stdout parser and one write to stdin.
10
+ *
11
+ * See docs/superpowers/specs/2026-08-08-claude-onboarding-design.md.
12
+ */
13
+ export interface AuthStatus {
14
+ loggedIn: boolean;
15
+ authMethod?: string;
16
+ email?: string;
17
+ subscriptionType?: string;
18
+ }
19
+ /** Pure: `claude auth status --json`. Anything unreadable reads as logged out. */
20
+ export declare function parseAuthStatus(stdout: string): AuthStatus;
21
+ /** Pure: the OAuth URL the CLI printed, or null if it hasn't printed one yet. */
22
+ export declare function parseLoginUrl(chunk: string): string | null;
23
+ /**
24
+ * Pure: the refusal the CLI printed for the code we submitted, if any.
25
+ *
26
+ * There is deliberately no "success" case. The invalid-code wording was
27
+ * captured from the real CLI; a success wording never was, and a parser that
28
+ * guessed one would produce a sign-in that silently never completes. Success is
29
+ * the child exiting cleanly — see `startLogin`.
30
+ */
31
+ export declare function parseLoginOutcome(chunk: string): "invalid-code" | null;
32
+ export declare function invalidateAuthStatus(): void;
33
+ export declare function authStatus(force?: boolean): Promise<AuthStatus>;
34
+ export declare function loginPending(): boolean;
35
+ export declare function cancelLogin(): void;
36
+ export declare function startLogin(): Promise<{
37
+ url: string;
38
+ } | {
39
+ error: string;
40
+ }>;
41
+ export declare function submitLoginCode(code: string): Promise<{
42
+ ok: true;
43
+ } | {
44
+ ok: false;
45
+ error: string;
46
+ }>;
@@ -0,0 +1,191 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ /** Pure: `claude auth status --json`. Anything unreadable reads as logged out. */
3
+ export function parseAuthStatus(stdout) {
4
+ try {
5
+ const j = JSON.parse(stdout);
6
+ if (!j || typeof j !== "object" || Array.isArray(j))
7
+ return { loggedIn: false };
8
+ if (j.loggedIn !== true)
9
+ return { loggedIn: false };
10
+ return {
11
+ loggedIn: true,
12
+ ...(typeof j.authMethod === "string" ? { authMethod: j.authMethod } : {}),
13
+ ...(typeof j.email === "string" ? { email: j.email } : {}),
14
+ ...(typeof j.subscriptionType === "string" ? { subscriptionType: j.subscriptionType } : {}),
15
+ };
16
+ }
17
+ catch {
18
+ return { loggedIn: false };
19
+ }
20
+ }
21
+ /**
22
+ * Strip ANSI/OSC escape sequences.
23
+ *
24
+ * The login output wraps the URL in an OSC 8 hyperlink
25
+ * (`ESC ] 8 ; ; <url> BEL <visible text> ESC ] 8 ; ; BEL`), so the URL is
26
+ * physically present TWICE and a naive `visit:\s*(\S+)` captures the escape
27
+ * plus a fragment of it. Stripping first makes the match trivial.
28
+ */
29
+ function stripEscapes(s) {
30
+ return s
31
+ // OSC 8 open (ESC ] 8 ; ; <url> BEL) and close (ESC ] 8 ; ; BEL).
32
+ .replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
33
+ // Ordinary CSI colour/cursor sequences.
34
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
35
+ }
36
+ /** Pure: the OAuth URL the CLI printed, or null if it hasn't printed one yet. */
37
+ export function parseLoginUrl(chunk) {
38
+ const m = stripEscapes(chunk).match(/visit:\s*(https:\/\/\S+)/);
39
+ return m ? m[1] : null;
40
+ }
41
+ /**
42
+ * Pure: the refusal the CLI printed for the code we submitted, if any.
43
+ *
44
+ * There is deliberately no "success" case. The invalid-code wording was
45
+ * captured from the real CLI; a success wording never was, and a parser that
46
+ * guessed one would produce a sign-in that silently never completes. Success is
47
+ * the child exiting cleanly — see `startLogin`.
48
+ */
49
+ export function parseLoginOutcome(chunk) {
50
+ return /Invalid code/i.test(stripEscapes(chunk)) ? "invalid-code" : null;
51
+ }
52
+ /** How long a status answer is reused. It is a spawn, and the card polls it. */
53
+ const STATUS_TTL_MS = 30_000;
54
+ /** The OAuth URL expires anyway; a child holding stdin open forever is worse. */
55
+ const FLOW_IDLE_MS = 10 * 60_000;
56
+ let cached = null;
57
+ export function invalidateAuthStatus() {
58
+ cached = null;
59
+ }
60
+ export async function authStatus(force = false) {
61
+ if (!force && cached && Date.now() - cached.at < STATUS_TTL_MS)
62
+ return cached.status;
63
+ const status = await new Promise((resolve) => {
64
+ execFile("claude", ["auth", "status", "--json"], { timeout: 15_000 }, (_err, stdout) => resolve(parseAuthStatus(stdout ?? "")));
65
+ });
66
+ cached = { at: Date.now(), status };
67
+ return status;
68
+ }
69
+ /**
70
+ * ONE flow at a time for the whole instance: the credentials are machine-global,
71
+ * so a second concurrent login would race the first for the same keychain
72
+ * entry. The upside is free — the web card and Telegram share the same URL, and
73
+ * a code pasted from either finishes the other's flow.
74
+ */
75
+ let flow = null;
76
+ export function loginPending() {
77
+ return flow !== null && !flow.ended;
78
+ }
79
+ export function cancelLogin() {
80
+ if (!flow)
81
+ return;
82
+ clearTimeout(flow.timer);
83
+ flow.child.kill();
84
+ flow = null;
85
+ }
86
+ export async function startLogin() {
87
+ // Reuse a flow that already printed its URL instead of replacing it. Each
88
+ // flow carries its own PKCE challenge, so restarting would silently kill the
89
+ // link the user is already looking at — which a plain page reload, or a
90
+ // second tab, would otherwise do.
91
+ if (flow && !flow.ended && flow.url)
92
+ return { url: flow.url };
93
+ cancelLogin();
94
+ const child = spawn("claude", ["auth", "login", "--claudeai"], {
95
+ // BROWSER is neutralised: on a desktop host the CLI would otherwise open a
96
+ // tab on the SERVER's machine, which is not where the user is.
97
+ env: { ...process.env, BROWSER: "/usr/bin/true" },
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ });
100
+ const f = {
101
+ child,
102
+ url: null,
103
+ out: "",
104
+ pending: null,
105
+ ended: false,
106
+ timer: setTimeout(() => cancelLogin(), FLOW_IDLE_MS),
107
+ };
108
+ flow = f;
109
+ const settle = (v) => {
110
+ const done = f.pending;
111
+ f.pending = null;
112
+ done?.(v);
113
+ };
114
+ const onData = (d) => {
115
+ f.out += d.toString();
116
+ if (parseLoginOutcome(f.out) === "invalid-code")
117
+ settle("invalid-code");
118
+ };
119
+ child.stdout.on("data", onData);
120
+ child.stderr.on("data", onData);
121
+ // SUCCESS IS AN EXIT, NOT A STRING. The "Invalid code…" wording was captured
122
+ // from the real CLI; the success wording never was, and inventing one would
123
+ // produce a sign-in that silently never completes. A clean exit after a code
124
+ // was submitted is the signal we can actually prove.
125
+ child.on("exit", (code) => {
126
+ f.ended = true;
127
+ settle(code === 0 ? "success" : "ended");
128
+ });
129
+ // A `claude` that is not on PATH must not leave the caller hanging until the
130
+ // 30s deadline with no explanation.
131
+ child.on("error", () => {
132
+ f.ended = true;
133
+ settle("ended");
134
+ });
135
+ return new Promise((resolve) => {
136
+ const deadline = setTimeout(() => {
137
+ cancelLogin();
138
+ resolve({ error: "claude auth login printed no URL within 30s" });
139
+ }, 30_000);
140
+ const poll = setInterval(() => {
141
+ const url = parseLoginUrl(f.out);
142
+ if (url) {
143
+ f.url = url;
144
+ clearInterval(poll);
145
+ clearTimeout(deadline);
146
+ resolve({ url });
147
+ }
148
+ else if (flow !== f || f.ended) {
149
+ clearInterval(poll);
150
+ clearTimeout(deadline);
151
+ resolve({ error: "the login process ended before printing a URL" });
152
+ }
153
+ }, 100);
154
+ });
155
+ }
156
+ export async function submitLoginCode(code) {
157
+ const f = flow;
158
+ if (!f || f.ended)
159
+ return { ok: false, error: "no sign-in is in progress — start one first" };
160
+ const trimmed = code.trim();
161
+ if (!trimmed)
162
+ return { ok: false, error: "empty code" };
163
+ // The user is clearly still here: push the idle deadline back.
164
+ clearTimeout(f.timer);
165
+ f.timer = setTimeout(() => cancelLogin(), FLOW_IDLE_MS);
166
+ // Forget what came before, so a stale "Invalid code" from a previous attempt
167
+ // is never mistaken for the verdict on THIS one.
168
+ f.out = "";
169
+ const verdict = await new Promise((resolve) => {
170
+ const t = setTimeout(() => {
171
+ f.pending = null;
172
+ resolve("timeout");
173
+ }, 30_000);
174
+ f.pending = (r) => {
175
+ clearTimeout(t);
176
+ resolve(r);
177
+ };
178
+ f.child.stdin.write(trimmed + "\n");
179
+ });
180
+ if (verdict === "success") {
181
+ cancelLogin();
182
+ invalidateAuthStatus();
183
+ return { ok: true };
184
+ }
185
+ if (verdict === "invalid-code")
186
+ return { ok: false, error: "Invalid code. Please make sure the full code was copied." };
187
+ if (verdict === "ended")
188
+ return { ok: false, error: "the sign-in was refused — start a new one and try again" };
189
+ return { ok: false, error: "the CLI did not answer within 30s" };
190
+ }
191
+ //# sourceMappingURL=claude-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-auth.js","sourceRoot":"","sources":["../src/claude-auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Seeding of Claude Code's first-run state.
3
+ *
4
+ * A virgin `claude` opens on its theme picker, then on a per-directory trust
5
+ * dialog, and never reaches a prompt: no input box, every submitted prompt
6
+ * fails, the tab reads "failed to start". That is how three zombie agents were
7
+ * once produced on a production container and found a day later.
8
+ *
9
+ * So shadok writes the handful of keys that answer those screens. The module is
10
+ * PURELY ADDITIVE — it only ever adds keys that are absent — which is what
11
+ * makes it safe to run unconditionally. Unlike `src/ssh.ts`, which had to
12
+ * restrict itself to containers (invariant 21), no Docker gate is needed here:
13
+ * on a machine that has used Claude Code before, the plan is empty and nothing
14
+ * is written at all.
15
+ *
16
+ * See docs/superpowers/specs/2026-08-08-claude-onboarding-design.md.
17
+ */
18
+ export interface ClaudeHome {
19
+ hasCompletedOnboarding?: boolean;
20
+ lastOnboardingVersion?: string;
21
+ theme?: string;
22
+ projects?: Record<string, Record<string, unknown>>;
23
+ [k: string]: unknown;
24
+ }
25
+ /** Pure: the semver out of `claude --version` ("2.1.226 (Claude Code)"). */
26
+ export declare function parseClaudeVersion(stdout: string): string | null;
27
+ /**
28
+ * Pure: the object to write, or null when nothing is missing.
29
+ *
30
+ * Returning null (rather than an identical object) is what lets the writer skip
31
+ * the write entirely — a multi-megabyte file is not rewritten for nothing.
32
+ */
33
+ export declare function seedPlan(existing: ClaudeHome, opts: {
34
+ version: string;
35
+ cwd?: string;
36
+ }): ClaudeHome | null;
37
+ export interface ClaudeSettings {
38
+ tui?: string;
39
+ [k: string]: unknown;
40
+ }
41
+ /**
42
+ * Pure: `~/.claude/settings.json` after adding the keys that suppress a
43
+ * first-run *upsell*, or null when nothing is missing.
44
+ *
45
+ * The fullscreen upsell ("Flicker-free output · Mouse support · Selected text
46
+ * auto-copies") is not gated by the onboarding flags — it appears AFTER the
47
+ * sign-in, which is why no probe caught it before a real account reached the
48
+ * screen. It is counted by `fullscreenUpsellSeenCount`, but seeding a counter
49
+ * means guessing its threshold; recording an explicit `tui` preference is the
50
+ * durable answer, because a choice already made cannot be upsold. Evidence: a
51
+ * developer machine with `tui` set has never accumulated that counter in 792
52
+ * startups.
53
+ */
54
+ export declare function settingsPlan(existing: ClaudeSettings): ClaudeSettings | null;
55
+ /** Called once at boot: the globals, plus the settings that kill the upsell. */
56
+ export declare function ensureClaudeHome(): void;
57
+ /**
58
+ * Called before EVERY spawn, for that session's directory.
59
+ *
60
+ * This cannot be a boot-time-only concern: a worktree is a brand-new directory
61
+ * for every agent, therefore a brand-new trust dialog every time.
62
+ */
63
+ export declare function ensureProjectTrusted(cwd: string): void;
@@ -0,0 +1,169 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** The per-project keys that answer the trust dialog. */
6
+ const PROJECT_KEYS = ["hasTrustDialogAccepted", "hasCompletedProjectOnboarding"];
7
+ /** Pure: the semver out of `claude --version` ("2.1.226 (Claude Code)"). */
8
+ export function parseClaudeVersion(stdout) {
9
+ const m = stdout.match(/^\s*(\d+\.\d+\.\d+[^\s(]*)/);
10
+ return m ? m[1] : null;
11
+ }
12
+ /**
13
+ * Pure: the object to write, or null when nothing is missing.
14
+ *
15
+ * Returning null (rather than an identical object) is what lets the writer skip
16
+ * the write entirely — a multi-megabyte file is not rewritten for nothing.
17
+ */
18
+ export function seedPlan(existing, opts) {
19
+ const out = { ...existing };
20
+ let changed = false;
21
+ const add = (key, value) => {
22
+ if (!(key in out)) {
23
+ out[key] = value;
24
+ changed = true;
25
+ }
26
+ };
27
+ // These two answer the theme picker. `theme` is deliberately NOT written:
28
+ // verified 2026-08-08 that the CLI DELETES an unknown top-level `theme` key
29
+ // on its next write, and that the picker is already skipped without it — so
30
+ // seeding one would be cargo cult that also implies we control the theme.
31
+ add("hasCompletedOnboarding", true);
32
+ add("lastOnboardingVersion", opts.version);
33
+ if (opts.cwd) {
34
+ const projects = { ...(existing.projects ?? {}) };
35
+ const entry = { ...(projects[opts.cwd] ?? {}) };
36
+ let entryChanged = false;
37
+ for (const key of PROJECT_KEYS) {
38
+ if (!(key in entry)) {
39
+ entry[key] = true;
40
+ entryChanged = true;
41
+ }
42
+ }
43
+ if (entryChanged) {
44
+ projects[opts.cwd] = entry;
45
+ out.projects = projects;
46
+ changed = true;
47
+ }
48
+ }
49
+ return changed ? out : null;
50
+ }
51
+ /**
52
+ * Pure: `~/.claude/settings.json` after adding the keys that suppress a
53
+ * first-run *upsell*, or null when nothing is missing.
54
+ *
55
+ * The fullscreen upsell ("Flicker-free output · Mouse support · Selected text
56
+ * auto-copies") is not gated by the onboarding flags — it appears AFTER the
57
+ * sign-in, which is why no probe caught it before a real account reached the
58
+ * screen. It is counted by `fullscreenUpsellSeenCount`, but seeding a counter
59
+ * means guessing its threshold; recording an explicit `tui` preference is the
60
+ * durable answer, because a choice already made cannot be upsold. Evidence: a
61
+ * developer machine with `tui` set has never accumulated that counter in 792
62
+ * startups.
63
+ */
64
+ export function settingsPlan(existing) {
65
+ if ("tui" in existing)
66
+ return null; // the user's own choice, never overridden
67
+ // "fullscreen" is the value both working installations run — shadok reads
68
+ // content from the transcript, not the screen, so the TUI's rendering mode
69
+ // does not affect it either way.
70
+ return { ...existing, tui: "fullscreen" };
71
+ }
72
+ const homeFile = () => path.join(os.homedir(), ".claude.json");
73
+ const settingsFile = () => path.join(os.homedir(), ".claude", "settings.json");
74
+ /** The claude CLI's version, or a conservative fallback. */
75
+ function claudeVersion() {
76
+ try {
77
+ return (parseClaudeVersion(execFileSync("claude", ["--version"], { encoding: "utf8" })) ?? "0.0.0");
78
+ }
79
+ catch {
80
+ return "0.0.0";
81
+ }
82
+ }
83
+ /**
84
+ * Read the file, or null when it exists but does not parse.
85
+ *
86
+ * Null means "leave it alone". We never "repair" an unparseable ~/.claude.json
87
+ * by overwriting it: the file carries the whole per-project history, and
88
+ * destroying it costs incomparably more than the screen this module avoids.
89
+ */
90
+ function readHome(file) {
91
+ if (!fs.existsSync(file))
92
+ return {};
93
+ try {
94
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
95
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Atomic write: a temp file in the SAME directory, then rename. An interrupted
103
+ * write must never leave a truncated ~/.claude.json behind.
104
+ */
105
+ function writeHome(file, data) {
106
+ const tmp = `${file}.shadok-${process.pid}.tmp`;
107
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
108
+ fs.renameSync(tmp, file);
109
+ }
110
+ /** Apply a plan for `cwd` (or the globals only when cwd is undefined). */
111
+ function seed(cwd) {
112
+ try {
113
+ const file = homeFile();
114
+ const existing = readHome(file);
115
+ if (existing === null)
116
+ return; // unparseable → hands off
117
+ const plan = seedPlan(existing, { version: claudeVersion(), cwd });
118
+ if (plan)
119
+ writeHome(file, plan);
120
+ }
121
+ catch {
122
+ // A failed seed must never take down the boot path or a spawn — same rule
123
+ // as ensureSshIdentity.
124
+ }
125
+ }
126
+ /** Called once at boot: the globals, plus the settings that kill the upsell. */
127
+ export function ensureClaudeHome() {
128
+ seed();
129
+ seedSettings();
130
+ }
131
+ /** Apply `settingsPlan` to `~/.claude/settings.json`. Same rules as `seed`. */
132
+ function seedSettings() {
133
+ try {
134
+ const file = settingsFile();
135
+ let existing = {};
136
+ if (fs.existsSync(file)) {
137
+ try {
138
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
139
+ existing =
140
+ parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
141
+ }
142
+ catch {
143
+ existing = null; // hand-edited into invalid JSON → never clobber it
144
+ }
145
+ }
146
+ if (existing === null)
147
+ return;
148
+ const plan = settingsPlan(existing);
149
+ if (!plan)
150
+ return;
151
+ fs.mkdirSync(path.dirname(file), { recursive: true });
152
+ const tmp = `${file}.shadok-${process.pid}.tmp`;
153
+ fs.writeFileSync(tmp, JSON.stringify(plan, null, 2));
154
+ fs.renameSync(tmp, file);
155
+ }
156
+ catch {
157
+ // Never take down the boot path.
158
+ }
159
+ }
160
+ /**
161
+ * Called before EVERY spawn, for that session's directory.
162
+ *
163
+ * This cannot be a boot-time-only concern: a worktree is a brand-new directory
164
+ * for every agent, therefore a brand-new trust dialog every time.
165
+ */
166
+ export function ensureProjectTrusted(cwd) {
167
+ seed(cwd);
168
+ }
169
+ //# sourceMappingURL=claude-home.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-home.js","sourceRoot":"","sources":["../src/claude-home.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AA4B7B,yDAAyD;AACzD,MAAM,YAAY,GAAG,CAAC,wBAAwB,EAAE,+BAA+B,CAAU,CAAC;AAE1F,4EAA4E;AAC5E,MAAM,UAAU,kBAAkB,CAAC,MAAc;IAC/C,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,4BAA4B,CAAC,CAAC;IACrD,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACzB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CACtB,QAAoB,EACpB,IAAuC;IAEvC,MAAM,GAAG,GAAe,EAAE,GAAG,QAAQ,EAAE,CAAC;IACxC,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,GAAG,GAAG,CAAC,GAAW,EAAE,KAAc,EAAE,EAAE;QAC1C,IAAI,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC;YAClB,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;YACjB,OAAO,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC,CAAC;IACF,0EAA0E;IAC1E,4EAA4E;IAC5E,4EAA4E;IAC5E,0EAA0E;IAC1E,GAAG,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC;IACpC,GAAG,CAAC,uBAAuB,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAE3C,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;QACb,MAAM,QAAQ,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,CAAC;QAClD,MAAM,KAAK,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QAChD,IAAI,YAAY,GAAG,KAAK,CAAC;QACzB,KAAK,MAAM,GAAG,IAAI,YAAY,EAAE,CAAC;YAC/B,IAAI,CAAC,CAAC,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC;gBACpB,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;gBAClB,YAAY,GAAG,IAAI,CAAC;YACtB,CAAC;QACH,CAAC;QACD,IAAI,YAAY,EAAE,CAAC;YACjB,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;YAC3B,GAAG,CAAC,QAAQ,GAAG,QAAQ,CAAC;YACxB,OAAO,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;AAC9B,CAAC;AAOD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,QAAwB;IACnD,IAAI,KAAK,IAAI,QAAQ;QAAE,OAAO,IAAI,CAAC,CAAC,0CAA0C;IAC9E,0EAA0E;IAC1E,2EAA2E;IAC3E,iCAAiC;IACjC,OAAO,EAAE,GAAG,QAAQ,EAAE,GAAG,EAAE,YAAY,EAAE,CAAC;AAC5C,CAAC;AAED,MAAM,QAAQ,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,cAAc,CAAC,CAAC;AACvE,MAAM,YAAY,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC;AAEvF,4DAA4D;AAC5D,SAAS,aAAa;IACpB,IAAI,CAAC;QACH,OAAO,CACL,kBAAkB,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,OAAO,CAC3F,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,QAAQ,CAAC,IAAY;IAC5B,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QACzD,OAAO,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IACxF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,IAAY,EAAE,IAAgB;IAC/C,MAAM,GAAG,GAAG,GAAG,IAAI,WAAW,OAAO,CAAC,GAAG,MAAM,CAAC;IAChD,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACtE,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AAC3B,CAAC;AAED,0EAA0E;AAC1E,SAAS,IAAI,CAAC,GAAY;IACxB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,QAAQ,EAAE,CAAC;QACxB,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,CAAC,0BAA0B;QACzD,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,aAAa,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QACnE,IAAI,IAAI;YAAE,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,0EAA0E;QAC1E,wBAAwB;IAC1B,CAAC;AACH,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,gBAAgB;IAC9B,IAAI,EAAE,CAAC;IACP,YAAY,EAAE,CAAC;AACjB,CAAC;AAED,+EAA+E;AAC/E,SAAS,YAAY;IACnB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;QAC5B,IAAI,QAAQ,GAA0B,EAAE,CAAC;QACzC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;gBACzD,QAAQ;oBACN,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;YACnF,CAAC;YAAC,MAAM,CAAC;gBACP,QAAQ,GAAG,IAAI,CAAC,CAAC,mDAAmD;YACtE,CAAC;QACH,CAAC;QACD,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO;QAC9B,MAAM,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;QACpC,IAAI,CAAC,IAAI;YAAE,OAAO;QAClB,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,MAAM,GAAG,GAAG,GAAG,IAAI,WAAW,OAAO,CAAC,GAAG,MAAM,CAAC;QAChD,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QACrD,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,iCAAiC;IACnC,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAW;IAC9C,IAAI,CAAC,GAAG,CAAC,CAAC;AACZ,CAAC"}
package/dist/server.js CHANGED
@@ -15,13 +15,15 @@ import { extractLiveText } from "../public/live-text.js";
15
15
  import { findTransientErrors, newTransientErrors, RETRY_DELAYS_MS } from "./retry.js";
16
16
  import { screenShowsWork } from "./detect.js";
17
17
  import { PtyPilot } from "./session.js";
18
+ import { ensureClaudeHome, ensureProjectTrusted } from "./claude-home.js";
19
+ import { authStatus, cancelLogin, startLogin, submitLoginCode } from "./claude-auth.js";
18
20
  import { ensureSshIdentity } from "./ssh.js";
19
21
  import { TmuxPilot, tmuxAvailable, tmuxHasSession, tmuxKillSession, tmuxPaneCwd } from "./tmux.js";
20
22
  import { scanUsage, sessionFilePath, tailSession, clearTailPos, isNothingToShow } from "./tail.js";
21
23
  import { computePace, paceBlock, WINDOW_SEC } from "./pace.js";
22
24
  import { getUsage } from "./usage.js";
23
25
  import { loadChannels, loadGroups, saveGroups, upsertChannel, removeChannel, mergeClientChannels, loadTgGroup, saveTgGroup, } from "./channels.js";
24
- import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, } from "./telegram.js";
26
+ import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, announceLoggedOut, resetLoggedOutNotice, } from "./telegram.js";
25
27
  import { migrateTgBindings } from "./channels.js";
26
28
  import { loadCrons, saveCrons, upsertCron, removeCron, resolveCronId, resolveCronTarget, nextRunFor, nextRunAfterFailure, isTransient, CRON_MAX_RETRIES, markCronPrompt, normalizeSchedule, scheduleLabel, cronTimeZone, defaultTimeZone, systemTimeZone, isValidTimeZone, } from "./crons.js";
27
29
  import { linkRefusal, markAgentPrompt, notificationText, } from "./kinship.js";
@@ -633,6 +635,26 @@ app.post("/tweak/prepare", (_req, res) => {
633
635
  });
634
636
  // Running version + latest seen on npm (null if not yet polled / check disabled).
635
637
  app.get("/version", (_req, res) => res.json({ current: OWN_VERSION, latest: latestKnown, autoUpdate, permissionMode }));
638
+ // Claude sign-in state. Instance-global — NOT per session — hence HTTP rather
639
+ // than a WS message: every tab and the Telegram bridge share one answer.
640
+ app.get("/auth", async (_req, res) => res.json(await authStatus()));
641
+ app.post("/auth/login", async (_req, res) => {
642
+ const r = await startLogin();
643
+ if ("error" in r)
644
+ return res.status(502).json(r);
645
+ res.json(r);
646
+ });
647
+ app.post("/auth/code", async (req, res) => {
648
+ const code = typeof req.body?.code === "string" ? req.body.code : "";
649
+ const r = await submitLoginCode(code);
650
+ if (r.ok)
651
+ resetLoggedOutNotice(); // the next sign-out gets announced again
652
+ res.status(r.ok ? 200 : 400).json(r);
653
+ });
654
+ app.delete("/auth/login", (_req, res) => {
655
+ cancelLogin();
656
+ res.json({ ok: true });
657
+ });
636
658
  // Channel list, persisted server-side per launch directory — survives a wiped
637
659
  // browser, another device, a restart or a reboot.
638
660
  /**
@@ -1078,6 +1100,9 @@ function sessionModelSetting(profileName) {
1078
1100
  }
1079
1101
  }
1080
1102
  function makePilot(id, cwd, args, profileName) {
1103
+ // A worktree is a brand-new directory, so it carries a brand-new trust
1104
+ // dialog. Seed it before the process exists, not after it is stuck on it.
1105
+ ensureProjectTrusted(cwd);
1081
1106
  const profile = profileName ? getProfile(profileName) : undefined;
1082
1107
  // Env = the vault secrets the profile references (none without a profile).
1083
1108
  // Kept apart from the SHADOK_* plumbing below: only THESE are secrets, and
@@ -1741,6 +1766,18 @@ wss.on("connection", (ws) => {
1741
1766
  case "start": {
1742
1767
  if (session)
1743
1768
  return fail("session already started");
1769
+ // Refusing here is what actually prevents zombies. The historical
1770
+ // failure was never "the login was missing" — it was "an agent was
1771
+ // allowed to start without one", and then sat on the first-run screen
1772
+ // for a day. `code` lets a machine client classify the refusal
1773
+ // without matching on message text (same contract as "busy").
1774
+ if (!(await authStatus()).loggedIn) {
1775
+ // Telling the user is the whole point: a cron refused at 4am must
1776
+ // not be discovered a day later. Deduplicated inside, so a
1777
+ // five-minute cron does not turn one sign-out into a flood.
1778
+ announceLoggedOut();
1779
+ return fail("this shadok-ai instance is not signed in to Claude — sign in from the cockpit", "logged-out");
1780
+ }
1744
1781
  if (typeof msg.origin === "string")
1745
1782
  origin = msg.origin.slice(0, 16);
1746
1783
  const cwd = msg.cwd?.trim() || process.cwd();
@@ -2265,6 +2302,10 @@ server.on("error", (err) => {
2265
2302
  process.exit(1);
2266
2303
  }
2267
2304
  });
2305
+ // Before anything can spawn: make sure `claude` will not open on its first-run
2306
+ // screens. reconcileOnBoot respawns sessions ~1s after boot, so this has to
2307
+ // happen first — that race is exactly what produced the zombie agents.
2308
+ ensureClaudeHome();
2268
2309
  // Persistent per-container SSH identity: in Docker, generate/reuse a key on the
2269
2310
  // shadok-data volume and point ~/.ssh at it so agents' git/ssh survive a
2270
2311
  // recreate. No-op on a normal host. Its GIT_SSH_COMMAND fallback is merged into