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.
Files changed (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. 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
- export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
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
- /** Verify `creds` belongs to `account`; null on match, else the mismatch reason. */
41
- async function identityMismatch(creds: OAuthCreds, account: Account): Promise<string | null> {
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: ${String((e as Error).message ?? e)}`;
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 null;
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 (${String((e as Error).message ?? e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
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 120s of expiry). Refresh + persist ourselves first.
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 add`" };
124
+ return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
90
125
  }
91
- return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
126
+ return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
92
127
  }
93
128
  }
94
129
 
95
- const mismatch = await identityMismatch(creds, account);
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: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
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 (${String((e as Error).message ?? e).slice(0, 80)})` };
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 mismatch = await identityMismatch(creds, account);
160
- if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
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;
@@ -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
+ }
@@ -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
- writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", 0o644);
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
- export function isOurCommand(cmd: string | undefined): boolean {
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: `${JSON.stringify(installedBin())} ${sub}` }] };
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?.some((h) => h.command?.includes(sub)));
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
- s.hooks![event] = arr.filter((g) => !g.hooks?.some((h) => h.command?.includes(sub)));
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
- type: "command",
82
- command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
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?.some((h) => h.command?.includes(sub)));
155
+ !!s.hooks?.[event]?.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
115
156
  return {
116
- statusLineOk: isOurCommand(s.statusLine?.command),
117
- subagentStatusLineOk: isOurCommand(s.subagentStatusLine?.command),
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
  };