tokenmaxxing 0.19.1 → 1.0.0
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/DESIGN.md +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/sample.ts
CHANGED
|
@@ -31,22 +31,32 @@ import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse
|
|
|
31
31
|
/** Result of a live sample: the fresh usage, or why it could not be taken.
|
|
32
32
|
* `pingError` is set only when a requested ping (status --force) failed - the
|
|
33
33
|
* account's 5h timer may not have started even if the sample itself succeeded. */
|
|
34
|
-
|
|
34
|
+
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
35
35
|
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
|
|
36
36
|
z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
|
|
37
37
|
]);
|
|
38
38
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const IdentityCheckSchema = z.discriminatedUnion("status", [
|
|
41
|
+
z.object({ status: z.literal("match") }),
|
|
42
|
+
z.object({ status: z.literal("mismatch"), reason: z.string() }),
|
|
43
|
+
z.object({ status: z.literal("unavailable"), reason: z.string() }),
|
|
44
|
+
]);
|
|
45
|
+
type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
|
|
46
|
+
|
|
47
|
+
/** Verify `creds` belongs to `account`. Only a definitive org DISAGREEMENT is
|
|
48
|
+
* a mismatch; an unreachable roles endpoint is "unavailable" and must never
|
|
49
|
+
* bench the account - a transient outage is not a dead credential, and
|
|
50
|
+
* flagging on it once removed every parked account from switching. */
|
|
51
|
+
async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
|
|
42
52
|
let org: RolesResponse;
|
|
43
53
|
try {
|
|
44
54
|
org = await fetchTokenOrg(creds.accessToken);
|
|
45
55
|
} catch (e) {
|
|
46
|
-
return `credential identity check failed: ${
|
|
56
|
+
return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
47
57
|
}
|
|
48
|
-
if (org.organization_uuid === account.organizationUuid) return
|
|
49
|
-
return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})
|
|
58
|
+
if (org.organization_uuid === account.organizationUuid) return { status: "match" };
|
|
59
|
+
return { status: "mismatch", reason: `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})` };
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/** Stamp the blob's plan fields onto the account (caller persists). Runs only
|
|
@@ -74,11 +84,36 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
74
84
|
try {
|
|
75
85
|
creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
|
|
76
86
|
} catch (e) {
|
|
77
|
-
return { ok: false, reason: `parked credential unreadable (${
|
|
87
|
+
return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The parked copy must never be refreshed (or probed - the probe can rotate
|
|
91
|
+
// it too) while its account secretly owns the LIVE login: after a crash
|
|
92
|
+
// between performSwap's live install and the oauthAccount rewrite, status
|
|
93
|
+
// still routes the live account here, and a parked-side rotation would
|
|
94
|
+
// supersede the live item's single-use refresh token out from under the
|
|
95
|
+
// running session - or, if claude rotated first, falsely flag the healthy
|
|
96
|
+
// live account needsReauth (closing-review catch; mirrors the codex
|
|
97
|
+
// sampler's present-account invariant). Verified against the live blob's
|
|
98
|
+
// TRUE org, fail-closed like the rm guard: an unverifiable live owner
|
|
99
|
+
// refuses the sample rather than risking the live grant.
|
|
100
|
+
const liveRaw = await readItem(liveTarget());
|
|
101
|
+
if (liveRaw != null) {
|
|
102
|
+
let liveOrg: string;
|
|
103
|
+
try {
|
|
104
|
+
const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
105
|
+
liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
|
|
108
|
+
}
|
|
109
|
+
if (liveOrg === account.organizationUuid) {
|
|
110
|
+
return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
|
|
111
|
+
}
|
|
78
112
|
}
|
|
79
113
|
|
|
80
114
|
// Hand claude a token with comfortable headroom so it won't run its own refresh
|
|
81
|
-
// (which claude does within
|
|
115
|
+
// (which claude does within 300s of expiry - the same margin checked here).
|
|
116
|
+
// Refresh + persist ourselves first.
|
|
82
117
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
83
118
|
try {
|
|
84
119
|
creds = await refreshCredential(creds);
|
|
@@ -86,16 +121,19 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
86
121
|
} catch (e) {
|
|
87
122
|
if (e instanceof InvalidGrantError) {
|
|
88
123
|
account.needsReauth = true;
|
|
89
|
-
return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing
|
|
124
|
+
return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
|
|
90
125
|
}
|
|
91
|
-
return { ok: false, reason: `token refresh failed: ${
|
|
126
|
+
return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
92
127
|
}
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
const
|
|
96
|
-
if (mismatch) {
|
|
130
|
+
const identity = await checkIdentity(creds, account);
|
|
131
|
+
if (identity.status === "mismatch") {
|
|
97
132
|
account.needsReauth = true;
|
|
98
|
-
return { ok: false, reason: `${
|
|
133
|
+
return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
|
|
134
|
+
}
|
|
135
|
+
if (identity.status === "unavailable") {
|
|
136
|
+
return { ok: false, reason: identity.reason };
|
|
99
137
|
}
|
|
100
138
|
refreshPlanFields(account, creds);
|
|
101
139
|
|
|
@@ -124,6 +162,35 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
124
162
|
}
|
|
125
163
|
}
|
|
126
164
|
|
|
165
|
+
/** Refresh the LIVE access token when near expiry, under claude's own refresh
|
|
166
|
+
* lock. Exported for `xx status`: every parked probe's fail-closed live-owner
|
|
167
|
+
* check reads this token, so it must be fresh BEFORE those probes run - even
|
|
168
|
+
* when the active account's own usage comes from the statusline tee and no
|
|
169
|
+
* active probe happens (cubic review catch, PR #35: the tee short-circuit
|
|
170
|
+
* skipped the refresh and every parked sample 401'd on the first post-idle
|
|
171
|
+
* status). No live item, or an unparsable one, is a no-op here: the callers'
|
|
172
|
+
* own guards surface those loudly. InvalidGrantError propagates. */
|
|
173
|
+
export async function ensureLiveTokenFresh(): Promise<void> {
|
|
174
|
+
const liveRaw = await readItem(liveTarget());
|
|
175
|
+
if (!liveRaw) return;
|
|
176
|
+
let creds: OAuthCreds;
|
|
177
|
+
try {
|
|
178
|
+
creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
179
|
+
} catch {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!isAccessTokenExpiring(creds, 300_000)) return;
|
|
183
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
184
|
+
const raw2 = await readItem(liveTarget());
|
|
185
|
+
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
186
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
187
|
+
const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
|
|
188
|
+
if (next === current) return;
|
|
189
|
+
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
|
190
|
+
await writeItem(liveTarget(), mergeIntoLive(raw2, next));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
127
194
|
/**
|
|
128
195
|
* Live-sample the ACTIVE account off the live login, verifying the live
|
|
129
196
|
* credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
|
|
@@ -132,32 +199,25 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
132
199
|
* the identity check - never spend quota on a drifted credential).
|
|
133
200
|
*/
|
|
134
201
|
export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
202
|
+
// A running claude keeps the live token fresh; after long idle it may not have.
|
|
203
|
+
try {
|
|
204
|
+
await ensureLiveTokenFresh();
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
|
|
207
|
+
return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
208
|
+
}
|
|
135
209
|
const liveRaw = await readItem(liveTarget());
|
|
136
210
|
if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
|
|
137
211
|
let creds: OAuthCreds;
|
|
138
212
|
try {
|
|
139
213
|
creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
140
214
|
} catch (e) {
|
|
141
|
-
return { ok: false, reason: `live credential blob unreadable (${
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// A running claude keeps the live token fresh; after long idle it may not have.
|
|
145
|
-
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
146
|
-
try {
|
|
147
|
-
await withClaudeRefreshLock(async () => {
|
|
148
|
-
const raw2 = (await readItem(liveTarget())) ?? liveRaw;
|
|
149
|
-
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
150
|
-
creds = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
|
|
151
|
-
if (creds !== current) await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
152
|
-
});
|
|
153
|
-
} catch (e) {
|
|
154
|
-
if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
|
|
155
|
-
return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
|
|
156
|
-
}
|
|
215
|
+
return { ok: false, reason: `live credential blob unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})` };
|
|
157
216
|
}
|
|
158
217
|
|
|
159
|
-
const
|
|
160
|
-
if (mismatch) return { ok: false, reason: `live ${
|
|
218
|
+
const identity = await checkIdentity(creds, account);
|
|
219
|
+
if (identity.status === "mismatch") return { ok: false, reason: `live ${identity.reason} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
220
|
+
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
161
221
|
refreshPlanFields(account, creds);
|
|
162
222
|
|
|
163
223
|
const pingError = opts.ping ? await pingSession() : null;
|
package/src/lib/sessions.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// recovery in #20) re-applies them instead of dropping --dangerously-skip-
|
|
4
4
|
// permissions / --model / etc.
|
|
5
5
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { paths } from "./paths.ts";
|
|
@@ -11,6 +11,12 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
11
11
|
|
|
12
12
|
const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
|
|
13
13
|
|
|
14
|
+
// Matches claude's default transcript retention (cleanupPeriodDays 30): a
|
|
15
|
+
// transcript claude has already deleted cannot be resumed, so its flags file
|
|
16
|
+
// is dead weight. saveSessionFlags rewrites the file on every (re)launch, so
|
|
17
|
+
// an actively resumed session keeps its mtime fresh and is never pruned.
|
|
18
|
+
const SESSION_RETENTION_MS = 30 * 24 * 3600 * 1000;
|
|
19
|
+
|
|
14
20
|
function sessionFile(sid: string): string {
|
|
15
21
|
return join(paths.home, "sessions", `${sid}.json`);
|
|
16
22
|
}
|
|
@@ -25,3 +31,19 @@ export function loadSessionFlags(sid: string): string[] | null {
|
|
|
25
31
|
if (!existsSync(f)) return null;
|
|
26
32
|
return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
|
|
27
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Delete session files past the retention window (also reaps stale
|
|
36
|
+
* writeFileAtomic temp siblings from a crashed writer). */
|
|
37
|
+
export function pruneStaleSessions(now: number): void {
|
|
38
|
+
const dir = join(paths.home, "sessions");
|
|
39
|
+
if (!existsSync(dir)) return;
|
|
40
|
+
for (const f of readdirSync(dir)) {
|
|
41
|
+
const p = join(dir, f);
|
|
42
|
+
try {
|
|
43
|
+
if (now - statSync(p).mtimeMs > SESSION_RETENTION_MS) rmSync(p, { force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
// A concurrent writeFileAtomic renames its tmp sibling away between
|
|
46
|
+
// readdir and stat; a vanished entry needs no pruning.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// are ours outright - tokenmaxxing renders them natively, so any other
|
|
5
5
|
// statusLine command is replaced.
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { paths } from "./paths.ts";
|
|
@@ -39,11 +39,15 @@ function readSettings(): Settings {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function writeSettings(s: Settings): void {
|
|
42
|
-
|
|
42
|
+
// Preserve the user's mode: settings.json can carry an env block with
|
|
43
|
+
// credentials, and the atomic rename would otherwise widen a 0600 file to
|
|
44
|
+
// world-readable. A brand-new file starts at the conservative 0600.
|
|
45
|
+
const mode = existsSync(paths.claudeSettings) ? statSync(paths.claudeSettings).mode & 0o777 : 0o600;
|
|
46
|
+
writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", mode);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/** True if a hook/statusline command string is one tokenmaxxing installed. */
|
|
46
|
-
|
|
50
|
+
function isOurCommand(cmd: string | undefined): boolean {
|
|
47
51
|
if (!cmd) return false;
|
|
48
52
|
return (
|
|
49
53
|
cmd.includes(SUBCMD.statusline) ||
|
|
@@ -55,36 +59,69 @@ export function isOurCommand(cmd: string | undefined): boolean {
|
|
|
55
59
|
);
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/** The exact command string installSettings writes for a subcommand; doctor
|
|
63
|
+
* compares against it verbatim, so a green check proves the canonical entry. */
|
|
64
|
+
function ourCommand(sub: string): string {
|
|
65
|
+
return `${JSON.stringify(installedBin())} ${sub}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
function ourHookGroup(sub: string): HookGroup {
|
|
59
|
-
return { hooks: [{ type: "command", command:
|
|
69
|
+
return { hooks: [{ type: "command", command: ourCommand(sub) }] };
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
function appendHook(s: Settings, event: string, sub: string): void {
|
|
63
73
|
s.hooks ??= {};
|
|
64
74
|
s.hooks[event] ??= [];
|
|
65
75
|
const arr = s.hooks[event]!;
|
|
66
|
-
const present = arr.some((g) => g.hooks
|
|
76
|
+
const present = arr.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
|
|
67
77
|
if (!present) arr.push(ourHookGroup(sub));
|
|
68
78
|
}
|
|
69
79
|
|
|
80
|
+
/** True only for a command tokenmaxxing itself wrote - the exact historical
|
|
81
|
+
* shape `"<...>/tokenmaxxing" <sub>` at ANY install path (so stale
|
|
82
|
+
* pre-relocation entries match too). A foreign command that merely mentions
|
|
83
|
+
* the subcommand or the path as text is NOT ours and must survive removal. */
|
|
84
|
+
/** Structural ownership: exactly `"<path>/tokenmaxxing" <sub>`. Exported for
|
|
85
|
+
* the codex hooks.json installer, whose old includes()-based match deleted
|
|
86
|
+
* foreign hooks sharing a group and misclassified commands merely mentioning
|
|
87
|
+
* the subcommand (closing-review catch; the same class settings.ts's own
|
|
88
|
+
* removeHook was fixed for in PR #31). */
|
|
89
|
+
export function isOurHookCommand(cmd: string, sub: string): boolean {
|
|
90
|
+
if (!cmd.endsWith(` ${sub}`)) return false;
|
|
91
|
+
const quotedPath = cmd.slice(0, cmd.length - (sub.length + 1));
|
|
92
|
+
if (!quotedPath.startsWith('"') || !quotedPath.endsWith('"')) return false;
|
|
93
|
+
let path: unknown;
|
|
94
|
+
try {
|
|
95
|
+
path = JSON.parse(quotedPath);
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const parsed = z.string().safeParse(path);
|
|
100
|
+
return parsed.success && parsed.data.endsWith("/tokenmaxxing");
|
|
101
|
+
}
|
|
102
|
+
|
|
70
103
|
function removeHook(s: Settings, event: string, sub: string): void {
|
|
71
104
|
const arr = s.hooks?.[event];
|
|
72
105
|
if (!arr) return;
|
|
73
|
-
|
|
106
|
+
// Strip only VERIFIED tokenmaxxing-owned entries from WITHIN each group:
|
|
107
|
+
// foreign hooks sharing a group - or merely mentioning our strings - survive
|
|
108
|
+
// (review catches, PR #31), and a group is dropped only once it is empty.
|
|
109
|
+
for (const g of arr) g.hooks = g.hooks.filter((h) => !isOurHookCommand(h.command, sub));
|
|
110
|
+
s.hooks![event] = arr.filter((g) => g.hooks.length > 0);
|
|
74
111
|
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
75
112
|
}
|
|
76
113
|
|
|
77
|
-
/** Install the entries: take both statusLine slots, append our hooks.
|
|
114
|
+
/** Install the entries: take both statusLine slots, append our hooks. Stale
|
|
115
|
+
* same-subcommand hooks from an OLD install path are dropped first, so a
|
|
116
|
+
* TOKENMAXXING_HOME relocation rewrites the entries instead of leaving dead
|
|
117
|
+
* paths that read as installed (relocation residue is how the supervisor
|
|
118
|
+
* recursion incident started). Foreign hooks are untouched. */
|
|
78
119
|
export function installSettings(): void {
|
|
79
120
|
const s = readSettings();
|
|
80
|
-
s.statusLine = {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
s.subagentStatusLine = {
|
|
85
|
-
type: "command",
|
|
86
|
-
command: `${JSON.stringify(installedBin())} ${SUBCMD.subagentStatusline}`,
|
|
87
|
-
};
|
|
121
|
+
s.statusLine = { type: "command", command: ourCommand(SUBCMD.statusline) };
|
|
122
|
+
s.subagentStatusLine = { type: "command", command: ourCommand(SUBCMD.subagentStatusline) };
|
|
123
|
+
removeHook(s, "Stop", SUBCMD.stop);
|
|
124
|
+
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
88
125
|
appendHook(s, "Stop", SUBCMD.stop);
|
|
89
126
|
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
90
127
|
writeSettings(s);
|
|
@@ -110,11 +147,15 @@ export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
|
|
|
110
147
|
|
|
111
148
|
export function checkSettings(): SettingsCheck {
|
|
112
149
|
const s = readSettings();
|
|
150
|
+
// Exact-command identity (review catch, PR #31): only the verbatim string
|
|
151
|
+
// installSettings writes counts as installed. A hook pointing at an OLD
|
|
152
|
+
// install path reads as broken, and a foreign command that merely mentions
|
|
153
|
+
// the path or subcommand as text never green-lights.
|
|
113
154
|
const has = (event: string, sub: string) =>
|
|
114
|
-
!!s.hooks?.[event]?.some((g) => g.hooks
|
|
155
|
+
!!s.hooks?.[event]?.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
|
|
115
156
|
return {
|
|
116
|
-
statusLineOk:
|
|
117
|
-
subagentStatusLineOk:
|
|
157
|
+
statusLineOk: s.statusLine?.command === ourCommand(SUBCMD.statusline),
|
|
158
|
+
subagentStatusLineOk: s.subagentStatusLine?.command === ourCommand(SUBCMD.subagentStatusline),
|
|
118
159
|
stopOk: has("Stop", SUBCMD.stop),
|
|
119
160
|
sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
|
|
120
161
|
};
|