ccm-account-manager 1.13.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.
@@ -0,0 +1,56 @@
1
+ // The signature: a split-flap cascade. On load and refresh, cells cycle
2
+ // through characters and settle left-to-right like a Solari departure board.
3
+
4
+ const FLAP_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789·▮%';
5
+
6
+ export class Cascade {
7
+ // Sweep left→right with slight per-row lag and jitter; ~600ms total.
8
+ constructor(now = Date.now()) {
9
+ this.t0 = now;
10
+ this.jitter = new Map();
11
+ }
12
+
13
+ settled(x, y, now = Date.now()) {
14
+ let j = this.jitter.get(`${x},${y}`);
15
+ if (j === undefined) {
16
+ j = Math.random() * 160;
17
+ this.jitter.set(`${x},${y}`, j);
18
+ }
19
+ return now - this.t0 >= x * 5 + y * 8 + j + 80;
20
+ }
21
+
22
+ active(w, h, now = Date.now()) {
23
+ return now - this.t0 < w * 5 + h * 8 + 260;
24
+ }
25
+
26
+ scrambler(now = Date.now()) {
27
+ return (cell, x, y) => {
28
+ if (cell.ch === ' ' || this.settled(x, y, now)) return cell.ch;
29
+ return FLAP_CHARS[(Math.random() * FLAP_CHARS.length) | 0];
30
+ };
31
+ }
32
+ }
33
+
34
+ // 3-row CCM wordmark; the middle row renders dim — the flap seam.
35
+ export const WORDMARK = [
36
+ '█▀▀▀ █▀▀▀ █▀▄▀█',
37
+ '█ █ █ ▀ █',
38
+ '▀▀▀▀ ▀▀▀▀ ▀ ▀',
39
+ ];
40
+
41
+ // A quota meter as a row of flap tiles: '██' cells with 1-char gaps
42
+ // (the 2px spacer rule), 8 tiles, filled count rounded from percent.
43
+ export const METER_TILES = 8;
44
+
45
+ export function meterCells(percent) {
46
+ const pct = Math.max(0, Math.min(100, percent ?? 0));
47
+ return Math.round((pct / 100) * METER_TILES);
48
+ }
49
+
50
+ export function drawMeter(canvas, x, y, percent, fillStyle, emptyStyle) {
51
+ const filled = meterCells(percent);
52
+ for (let i = 0; i < METER_TILES; i++) {
53
+ canvas.put(x + i * 3, y, i < filled ? '██' : '··', i < filled ? fillStyle : emptyStyle);
54
+ }
55
+ return x + METER_TILES * 3;
56
+ }
@@ -0,0 +1,103 @@
1
+ // Minimal truecolor terminal layer: alt-screen lifecycle, key decoding, and a
2
+ // cell canvas that serializes one frame per write (no flicker, no deps).
3
+
4
+ const colorOn = !process.env.NO_COLOR;
5
+
6
+ function hexRgb(hex) {
7
+ const n = parseInt(hex.slice(1), 16);
8
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
9
+ }
10
+
11
+ export function styleCode({ fg, bold, dim } = {}) {
12
+ if (!colorOn) return '';
13
+ const parts = ['0'];
14
+ if (bold) parts.push('1');
15
+ if (dim) parts.push('2');
16
+ if (fg) parts.push(`38;2;${hexRgb(fg).join(';')}`);
17
+ return `\x1b[${parts.join(';')}m`;
18
+ }
19
+
20
+ export class Canvas {
21
+ constructor(w, h) {
22
+ this.w = w;
23
+ this.h = h;
24
+ this.cells = Array.from({ length: h }, () => Array.from({ length: w }, () => ({ ch: ' ', style: null })));
25
+ }
26
+
27
+ put(x, y, text, style = null) {
28
+ if (y < 0 || y >= this.h) return;
29
+ const chars = [...String(text)];
30
+ for (let i = 0; i < chars.length; i++) {
31
+ const cx = x + i;
32
+ if (cx < 0 || cx >= this.w) continue;
33
+ this.cells[y][cx] = { ch: chars[i], style };
34
+ }
35
+ }
36
+
37
+ // Serialize to one ANSI string. `scramble(cell, x, y)` may substitute the
38
+ // displayed character — the split-flap cascade hooks in here.
39
+ toAnsi(scramble = null) {
40
+ const out = [];
41
+ for (let y = 0; y < this.h; y++) {
42
+ let row = '';
43
+ let last;
44
+ for (let x = 0; x < this.w; x++) {
45
+ const cell = this.cells[y][x];
46
+ const code = styleCode(cell.style ?? {});
47
+ if (code !== last) { row += code; last = code; }
48
+ row += scramble ? scramble(cell, x, y) : cell.ch;
49
+ }
50
+ out.push(row + (colorOn ? '\x1b[0m' : ''));
51
+ }
52
+ return out.join('\n');
53
+ }
54
+
55
+ // Plain text (for headless tests).
56
+ toText() {
57
+ return this.cells.map((r) => r.map((c) => c.ch).join('').trimEnd()).join('\n');
58
+ }
59
+ }
60
+
61
+ const KEYMAP = {
62
+ '\x1b[A': 'up', '\x1b[B': 'down', '\x1b[C': 'right', '\x1b[D': 'left',
63
+ '\r': 'enter', '\n': 'enter', '\x1b': 'esc', '\x03': 'ctrl-c',
64
+ '\x7f': 'backspace', '\b': 'backspace', '\t': 'tab',
65
+ };
66
+
67
+ export class Screen {
68
+ constructor() {
69
+ this.out = process.stdout;
70
+ this.onKey = null;
71
+ this.onResize = null;
72
+ this._data = (buf) => {
73
+ const s = buf.toString();
74
+ const key = KEYMAP[s] ?? (s.length === 1 ? s.toLowerCase() : null);
75
+ if (key && this.onKey) this.onKey(key, s); // raw form for text input
76
+ };
77
+ this._resize = () => this.onResize?.();
78
+ }
79
+
80
+ get size() {
81
+ return { w: this.out.columns ?? 80, h: this.out.rows ?? 24 };
82
+ }
83
+
84
+ enter() {
85
+ this.out.write('\x1b[?1049h\x1b[?25l');
86
+ process.stdin.setRawMode(true);
87
+ process.stdin.resume();
88
+ process.stdin.on('data', this._data);
89
+ this.out.on('resize', this._resize);
90
+ }
91
+
92
+ leave() {
93
+ process.stdin.off('data', this._data);
94
+ this.out.off('resize', this._resize);
95
+ process.stdin.setRawMode(false);
96
+ process.stdin.pause();
97
+ this.out.write('\x1b[?25h\x1b[?1049l');
98
+ }
99
+
100
+ frame(ansi) {
101
+ this.out.write('\x1b[H' + ansi);
102
+ }
103
+ }
@@ -0,0 +1,27 @@
1
+ // The Solari board tokens. Dark-committed: departure boards have no light mode.
2
+ // Account hues validated with the dataviz palette checker against #1A1A19
3
+ // (all ≥3:1 contrast, worst adjacent CVD ΔE 41.3 — 2026-07-06).
4
+ export const INK = '#F5F3EA'; // warm paper white — the flap text
5
+ export const INK2 = '#C3C2B7'; // secondary ink
6
+ export const MUTED = '#898781'; // labels, empty tiles
7
+ export const AMBER = '#FAB219'; // signal amber: time, headers. Time IS the warning.
8
+ export const GOOD = '#0CA30C';
9
+ export const SERIOUS = '#EC835A';
10
+ export const CRITICAL = '#D03B3B';
11
+
12
+ // Registry color name → validated dark-surface hue ("transit line" colors).
13
+ export const HUES = {
14
+ cyan: '#3987E5', magenta: '#D55181', yellow: '#C98500',
15
+ green: '#199E70', blue: '#9085E9', red: '#E66767',
16
+ };
17
+
18
+ export function hueOf(colorName) {
19
+ return HUES[colorName] ?? INK2;
20
+ }
21
+
22
+ // Meter fill color by usage percent: neutral ink until it becomes a warning.
23
+ export function meterColor(pct) {
24
+ if (pct >= 95) return CRITICAL;
25
+ if (pct >= 80) return AMBER;
26
+ return INK;
27
+ }
@@ -0,0 +1,431 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ccm — account board</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%231A1A19'/%3E%3Crect x='2' y='7' width='12' height='1' fill='%23000'/%3E%3Ctext x='8' y='12' font-size='10' font-family='monospace' fill='%23FAB219' text-anchor='middle'%3EC%3C/text%3E%3C/svg%3E">
8
+ <style>
9
+ :root{
10
+ --board:#0D0D0D; --tile:#1A1A19; --tile-top:#232320; --tile-bot:#161614;
11
+ --seam:rgba(0,0,0,.55); --seam-hi:rgba(255,255,255,.05);
12
+ --hair:rgba(255,255,255,.08);
13
+ --ink:#F5F3EA; --ink2:#C3C2B7; --muted:#898781;
14
+ --amber:#FAB219; --good:#0CA30C; --crit:#D03B3B;
15
+ --mono:'Cascadia Mono','Cascadia Code',ui-monospace,Consolas,monospace;
16
+ --sans:'Segoe UI Variable Text','Segoe UI',system-ui,sans-serif;
17
+ }
18
+ *{box-sizing:border-box;margin:0;padding:0}
19
+ html{background:var(--board)}
20
+ body{
21
+ font-family:var(--sans); color:var(--ink2); min-height:100vh;
22
+ background:
23
+ radial-gradient(1200px 500px at 50% -10%, rgba(250,178,25,.05), transparent 60%),
24
+ var(--board);
25
+ padding:48px 24px 80px;
26
+ }
27
+ .wrap{max-width:1080px;margin:0 auto}
28
+
29
+ /* ── flap cells ─────────────────────────────────────────────── */
30
+ .cell{
31
+ position:relative; display:inline-flex; align-items:center; justify-content:center;
32
+ width:1.28em; height:1.72em; margin-right:.09em; border-radius:.13em;
33
+ background:linear-gradient(180deg,var(--tile-top) 0%,var(--tile) 47%,var(--tile-bot) 53%,var(--tile) 100%);
34
+ font-family:var(--mono); color:var(--ink); overflow:hidden;
35
+ box-shadow:0 1px 2px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.03);
36
+ }
37
+ .cell::after{
38
+ content:''; position:absolute; left:0; right:0; top:calc(50% - 1px); height:1px;
39
+ background:var(--seam); box-shadow:0 1px 0 var(--seam-hi);
40
+ }
41
+ .cell.tick{animation:flip .16s ease-in-out}
42
+ @keyframes flip{0%{transform:perspective(240px) rotateX(0)}50%{transform:perspective(240px) rotateX(-72deg)}100%{transform:perspective(240px) rotateX(0)}}
43
+ @media (prefers-reduced-motion: reduce){ .cell.tick{animation:none} }
44
+ .amber .cell{color:var(--amber)}
45
+
46
+ /* ── station header ─────────────────────────────────────────── */
47
+ header{display:flex; align-items:flex-end; gap:24px; flex-wrap:wrap; margin-bottom:14px}
48
+ .wordmark{font-size:34px; letter-spacing:0; white-space:nowrap}
49
+ .sub{font-family:var(--mono); color:var(--amber); letter-spacing:.42em; font-size:12px; padding-bottom:10px; flex:1; min-width:260px}
50
+ .clock{font-size:22px; padding-bottom:2px}
51
+ .rule{height:1px; background:var(--hair); margin:0 0 6px}
52
+
53
+ /* ── the board ──────────────────────────────────────────────── */
54
+ .row{
55
+ display:grid; grid-template-columns:18px 250px 1fr auto auto; gap:0 18px; align-items:center;
56
+ padding:20px 8px; border-bottom:1px solid var(--hair);
57
+ }
58
+ .row:focus-visible{outline:2px solid var(--amber); outline-offset:-2px; border-radius:6px}
59
+ .line{font-size:15px; line-height:1}
60
+ .who{display:flex; flex-direction:column; gap:5px; min-width:0}
61
+ .name{font-size:19px}
62
+ .mail{font-family:var(--mono); font-size:12.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
63
+ .plan{font-family:var(--mono); font-size:10.5px; color:var(--muted); letter-spacing:.22em}
64
+ .meters{display:flex; flex-direction:column; gap:9px; min-width:0}
65
+ .meter{display:flex; align-items:center; gap:10px; font-family:var(--mono); font-size:12.5px}
66
+ .meter>label{color:var(--muted); width:6ch; letter-spacing:.1em; white-space:nowrap; overflow:hidden}
67
+ .tiles{display:flex; gap:2px}
68
+ .tile{width:15px; height:17px; border-radius:2.5px; background:linear-gradient(180deg,var(--tile-top),var(--tile-bot)); position:relative}
69
+ .tile::after{content:'';position:absolute;left:0;right:0;top:calc(50% - .5px);height:1px;background:var(--seam)}
70
+ .tile.on{background:linear-gradient(180deg,#FFFDF4,#DAD7C8)}
71
+ .tile.on.warn{background:linear-gradient(180deg,#FFC93E,#DE9A05)}
72
+ .tile.on.crit{background:linear-gradient(180deg,#E15656,#B32F2F)}
73
+ .pct{color:var(--ink2); width:4ch; text-align:right}
74
+ .resets{color:var(--amber); letter-spacing:.06em; white-space:nowrap}
75
+ .resets b{color:var(--muted); font-weight:400; margin-right:.6em; letter-spacing:.18em}
76
+ .nodata{font-family:var(--mono); font-size:12.5px; color:var(--muted)}
77
+ .meter.stale .tile.on{background:linear-gradient(180deg,#4a4a46,#333330)}
78
+ .meter.stale .pct,.meter.stale .resets,.meter.stale .resets b{color:var(--muted)}
79
+ .staleline{font-family:var(--mono); font-size:11.5px; color:var(--amber); margin-top:4px}
80
+ .chip{font-family:var(--mono); font-size:11px; letter-spacing:.14em; white-space:nowrap; justify-self:end}
81
+ .chip.good{color:var(--good)} .chip.warn{color:var(--amber)} .chip.crit{color:var(--crit)} .chip.stale{color:var(--muted)}
82
+ .board-btn{
83
+ font-family:var(--mono); font-size:12.5px; letter-spacing:.2em; cursor:pointer;
84
+ color:var(--amber); background:none; border:1px solid rgba(250,178,25,.45); border-radius:5px;
85
+ padding:9px 20px; transition:background .12s,color .12s;
86
+ }
87
+ .board-btn:hover{background:var(--amber); color:#141310}
88
+ .board-btn:focus-visible{outline:2px solid var(--amber); outline-offset:2px}
89
+
90
+ /* ── drawers ────────────────────────────────────────────────── */
91
+ section{margin-top:44px}
92
+ h2{font-family:var(--mono); font-size:12px; color:var(--amber); letter-spacing:.42em; font-weight:400; margin-bottom:4px}
93
+ .hint{font-size:12.5px; color:var(--muted); margin:6px 0 10px}
94
+ .scope{display:flex; gap:8px; margin:2px 0 12px}
95
+ .scope-btn.active{background:var(--amber); color:#141310}
96
+ .srow{display:grid; grid-template-columns:88px 100px 1fr 76px auto; gap:14px; align-items:center; padding:11px 8px; border-bottom:1px solid var(--hair); font-size:13px}
97
+ .fgroup{border-bottom:1px solid var(--hair)}
98
+ .fgroup summary{
99
+ display:flex; gap:12px; align-items:baseline; padding:13px 8px; cursor:pointer; list-style:none;
100
+ font-family:var(--mono); font-size:13px;
101
+ }
102
+ .fgroup summary::-webkit-details-marker{display:none}
103
+ .fgroup summary:focus-visible{outline:2px solid var(--amber); outline-offset:-2px}
104
+ .fgroup summary .fold{color:var(--amber); width:1em}
105
+ .fgroup summary .fdir{color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
106
+ .fgroup summary .fmeta{color:var(--muted); font-size:11px; letter-spacing:.14em; margin-left:auto; white-space:nowrap}
107
+ .fgroup[open] summary{border-bottom:1px solid var(--hair)}
108
+ .fgroup .srow{border-bottom:none; padding-left:28px}
109
+ .fgroup .srow+.srow{border-top:1px solid rgba(255,255,255,.04)}
110
+ .srow .sid{font-family:var(--mono); color:var(--muted); font-size:11px; text-align:right}
111
+ .srow .untitled{color:var(--muted); font-style:italic}
112
+ .srow .t{font-family:var(--mono); color:var(--ink2)}
113
+ .srow .from{font-family:var(--mono); color:var(--muted)}
114
+ .srow .id{font-family:var(--mono); color:var(--ink)}
115
+ .srow .title{color:var(--ink2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
116
+ .srow .act{display:flex; gap:8px; align-items:center}
117
+ select{
118
+ font-family:var(--mono); font-size:12px; color:var(--ink2); background:var(--tile);
119
+ border:1px solid var(--hair); border-radius:5px; padding:6px 8px;
120
+ }
121
+ .mini{padding:6px 14px; font-size:11.5px}
122
+ .mgroup{font-family:var(--mono); color:var(--ink); font-size:12px; letter-spacing:.14em; text-transform:uppercase; margin:16px 0 2px}
123
+ #mcp .srow{grid-template-columns:1fr auto}
124
+ #mcp .srow:first-of-type{border-top:none}
125
+ .mtag{font-family:var(--mono); font-size:10.5px; color:var(--muted); letter-spacing:.12em; margin-left:10px; padding:1px 7px; border:1px solid var(--hair); border-radius:4px}
126
+ #mcp .act .to{color:var(--amber)}
127
+ #mcp .act.muted{color:var(--muted); font-size:12px; font-family:var(--mono)}
128
+ .drow{display:flex; gap:10px; align-items:baseline; padding:6px 8px; font-size:13px}
129
+ .drow .lamp{font-family:var(--mono); width:1.2em}
130
+ .lamp.ok{color:var(--good)} .lamp.warn{color:var(--amber)} .lamp.err{color:var(--crit)}
131
+ .dgroup{font-family:var(--mono); color:var(--ink); letter-spacing:.14em; margin-top:10px; padding:4px 8px; font-size:12px}
132
+ footer{margin-top:52px; display:flex; gap:18px; align-items:center; font-family:var(--mono); font-size:11.5px; color:var(--muted)}
133
+ footer .cwd{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
134
+ #toast{
135
+ position:fixed; left:50%; bottom:28px; transform:translate(-50%,80px); transition:transform .25s;
136
+ background:var(--tile); color:var(--ink); border:1px solid var(--hair); border-radius:8px;
137
+ padding:12px 22px; font-family:var(--mono); font-size:13px; box-shadow:0 8px 30px rgba(0,0,0,.6);
138
+ visibility:hidden;
139
+ }
140
+ #toast.show{transform:translate(-50%,0); visibility:visible}
141
+ .empty{padding:26px 8px; color:var(--muted); font-size:13.5px}
142
+ .empty code{font-family:var(--mono); color:var(--ink2); background:var(--tile); padding:2px 8px; border-radius:4px}
143
+
144
+ @media (max-width:860px){
145
+ .row{grid-template-columns:18px 1fr auto; grid-template-rows:auto auto}
146
+ .meters{grid-column:2/4; grid-row:2; margin-top:12px}
147
+ .chip{display:none}
148
+ .srow{grid-template-columns:80px 90px 1fr; grid-template-rows:auto auto}
149
+ .srow .title{grid-column:1/4}
150
+ .srow .act{grid-column:1/4; justify-content:flex-end}
151
+ }
152
+ </style>
153
+ </head>
154
+ <body>
155
+ <div class="wrap">
156
+ <header>
157
+ <div class="wordmark" id="wordmark" aria-label="ccm"></div>
158
+ <div class="sub">CLAUDE&nbsp;CODE&nbsp;ACCOUNT&nbsp;BOARD</div>
159
+ <div class="clock amber" id="clock" aria-hidden="true"></div>
160
+ </header>
161
+ <div class="rule"></div>
162
+ <main id="board" aria-live="polite"></main>
163
+
164
+ <section>
165
+ <h2>TRANSFERS</h2>
166
+ <p class="hint">Resume a session where it lives, or transfer it to another account and continue there — the original stays put. Resuming opens the terminal in the session's own folder.</p>
167
+ <div class="scope" role="tablist">
168
+ <button class="board-btn mini scope-btn active" id="scope-here" onclick="setScope('here')">This folder</button>
169
+ <button class="board-btn mini scope-btn" id="scope-all" onclick="setScope('all')">All folders</button>
170
+ </div>
171
+ <div id="sessions"></div>
172
+ </section>
173
+
174
+ <section>
175
+ <h2>MCP SERVERS</h2>
176
+ <p class="hint">Each account's own MCP servers (user- and project-scoped). Copy one into another account — pick the target and whether it lands everywhere (user) or only in its project folder (local). Shared servers are already in every account.</p>
177
+ <div id="mcp"></div>
178
+ </section>
179
+
180
+ <section>
181
+ <h2>DOCTOR</h2>
182
+ <p class="hint">Health checks for profiles, tokens, junctions and integrations.</p>
183
+ <div id="doctor"><button class="board-btn mini" onclick="runDoctor()">Run checks</button></div>
184
+ </section>
185
+
186
+ <footer>
187
+ <span class="cwd" id="cwd"></span>
188
+ <span id="updated"></span>
189
+ <button class="board-btn mini" onclick="refreshUsage(this)">Refresh usage</button>
190
+ </footer>
191
+ </div>
192
+ <div id="toast" role="status"></div>
193
+
194
+ <script>
195
+ const FLAP='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:·%';
196
+ const REDUCED=matchMedia('(prefers-reduced-motion: reduce)').matches;
197
+ const $=(s,el=document)=>el.querySelector(s);
198
+
199
+ // ── split-flap text: a run of cells that scramble & settle ─────────────
200
+ function flapSet(el, text, cascade=true){
201
+ const chars=[...String(text)];
202
+ while(el.children.length>chars.length) el.lastChild.remove();
203
+ while(el.children.length<chars.length){
204
+ const c=document.createElement('span'); c.className='cell'; c.textContent=' '; el.append(c);
205
+ }
206
+ chars.forEach((ch,i)=>{
207
+ const cell=el.children[i];
208
+ if(cell.textContent===ch) return;
209
+ if(REDUCED || !cascade){ cell.textContent=ch; return; }
210
+ const spins=2+((Math.random()*3)|0);
211
+ let n=0;
212
+ cell.classList.remove('tick'); void cell.offsetWidth; cell.classList.add('tick');
213
+ const iv=setInterval(()=>{
214
+ cell.textContent = ++n>spins ? ch : FLAP[(Math.random()*FLAP.length)|0];
215
+ if(n>spins) clearInterval(iv);
216
+ }, 55+i*8);
217
+ });
218
+ }
219
+
220
+ flapSet($('#wordmark'),'CCM',true);
221
+ function tickClock(){ flapSet($('#clock'), new Date().toTimeString().slice(0,8), false); }
222
+ tickClock(); setInterval(tickClock,1000);
223
+
224
+ // ── board rows ─────────────────────────────────────────────────────────
225
+ let state=null;
226
+ function meterHtml(w, stale){
227
+ if(!w) return '';
228
+ const on=Math.round(w.percent/10), cls=stale?'':w.percent>=95?'crit':w.percent>=80?'warn':'';
229
+ const tiles=Array.from({length:10},(_,i)=>`<span class="tile ${i<on?'on '+cls:''}"></span>`).join('');
230
+ const label=w.label.startsWith('session')?'5H':w.label==='week (all models)'?'WK':w.label.replace(/^week \((.+)\)$/,'$1').toUpperCase().slice(0,6);
231
+ return `<div class="meter${stale?' stale':''}"><label>${label}</label><span class="tiles">${tiles}</span>`+
232
+ `<span class="pct">${Math.round(w.percent)}%</span>`+
233
+ `<span class="resets"><b>RESETS</b>${until(w.resetsAt)}</span></div>`;
234
+ }
235
+ function until(iso){
236
+ if(!iso) return '—';
237
+ const m=Math.max(0,Math.floor((new Date(iso)-Date.now())/60000));
238
+ const d=Math.floor(m/1440), h=Math.floor(m%1440/60), mm=m%60;
239
+ return d?`${d}D ${h}H`:h?`${h}H ${String(mm).padStart(2,'0')}M`:`${mm}M`;
240
+ }
241
+ function ago(t){
242
+ const m=Math.floor((Date.now()-t)/60000);
243
+ if(m<1) return 'just now';
244
+ if(m<60) return m+'m ago';
245
+ if(m<1440) return Math.floor(m/60)+'h ago';
246
+ return Math.floor(m/1440)+'d ago';
247
+ }
248
+ function chip(p){
249
+ if(p.loggedOut) return '<span class="chip crit">✕ LOGGED OUT</span>';
250
+ if(p.running) return '<span class="chip good">● IN SESSION</span>';
251
+ if(p.stale) return '<span class="chip stale">⟳ STALE</span>';
252
+ const worst=Math.max(0,...(p.windows??[]).map(w=>w.percent));
253
+ if(worst>=95) return '<span class="chip crit">■ FULL</span>';
254
+ if(worst>=80) return '<span class="chip warn">▲ ALMOST FULL</span>';
255
+ if(state && p.name===state.best) return '<span class="chip good">✦ MOST HEADROOM</span>';
256
+ return '<span class="chip"></span>';
257
+ }
258
+ const HINTS={'refresh-rejected':'login expired — launch this account to sign in again','token-expired':'launch this account once to refresh','no-refresh-token':'launch this account and run /login','refresh-network':'network error refreshing token','refresh-timeout':'timed out refreshing token'};
259
+ function renderBoard(){
260
+ const el=$('#board');
261
+ if(!state.profiles.length){
262
+ el.innerHTML=`<div class="empty">No accounts on the board yet. In a terminal: <code>ccm import work</code> to adopt your current login, <code>ccm add personal</code> for a second account.</div>`;
263
+ return;
264
+ }
265
+ state.best=state.profiles[0]?.headroom!=null?state.profiles[0].name:null;
266
+ el.innerHTML=state.profiles.map(p=>`
267
+ <div class="row" tabindex="0" data-name="${p.name}">
268
+ <span class="line" style="color:${p.hue}">●</span>
269
+ <div class="who">
270
+ <span class="name flapname" data-text="${p.name.toUpperCase()}"></span>
271
+ <span class="mail">${p.email??'not logged in'}</span>
272
+ ${p.plan?`<span class="plan">${p.plan.toUpperCase()}${p.organization&&!/'s organization$/i.test(p.organization)?' · '+p.organization.toUpperCase().slice(0,24):''}</span>`:''}
273
+ </div>
274
+ <div class="meters">${
275
+ p.windows ? p.windows.slice(0,3).map(w=>meterHtml(w,p.stale)).join('')
276
+ + (p.stale?`<div class="staleline">as of ${p.fetchedAt?ago(p.fetchedAt):'?'} · ${HINTS[p.staleError]??'could not refresh'}</div>`:'')
277
+ : `<span class="nodata">${HINTS[p.usageError]??'no usage data yet'}</span>`
278
+ }</div>
279
+ ${chip(p)}
280
+ <button class="board-btn" onclick="board('${p.name}')" title="${p.loggedOut?'Sign back in to this account':'Open a Windows Terminal tab on this account'}">${p.loggedOut?'Log in':'Board'}</button>
281
+ </div>`).join('');
282
+ el.querySelectorAll('.flapname').forEach(n=>flapSet(n,n.dataset.text));
283
+ el.querySelectorAll('.row').forEach(r=>r.addEventListener('keydown',e=>{
284
+ if(e.key==='Enter'&&e.target===r) board(r.dataset.name);
285
+ }));
286
+ }
287
+ let scope=new URLSearchParams(location.search).get('scope')==='all'?'all':'here';
288
+ function setScope(s){
289
+ scope=s;
290
+ $('#scope-here').classList.toggle('active',s==='here');
291
+ $('#scope-all').classList.toggle('active',s==='all');
292
+ history.replaceState(null,'',s==='all'?'?scope=all':location.pathname);
293
+ load();
294
+ }
295
+ $('#scope-here').classList.toggle('active',scope==='here');
296
+ $('#scope-all').classList.toggle('active',scope==='all');
297
+ function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); }
298
+ function sessionRow(s){
299
+ const targets=state.profiles.filter(p=>!(s.source.kind==='profile'&&p.name===s.source.label));
300
+ return `<div class="srow">
301
+ <span class="t">${ago(s.mtime)}</span>
302
+ <span class="from"><span style="color:${s.source.hue??'var(--muted)'}">${s.source.kind==='profile'?'●':'○'}</span> ${esc(s.source.label)}</span>
303
+ <span class="title">${s.title?esc(s.title):'<span class="untitled">untitled</span>'}</span>
304
+ <span class="sid">${s.id.slice(0,8)}</span>
305
+ <span class="act">
306
+ <select id="sel-${s.id.slice(0,8)}">${targets.map(t=>`<option>${esc(t.name)}</option>`).join('')}</select>
307
+ <button class="board-btn mini" onclick="transfer('${s.id}')">Transfer &amp; resume</button>
308
+ </span>
309
+ </div>`;
310
+ }
311
+ function renderSessions(){
312
+ const el=$('#sessions');
313
+ if(!state.sessions.length){
314
+ el.innerHTML=`<div class="empty">${scope==='all'?'No sessions on any account yet.':'No sessions for this folder yet — switch to “All folders” or work with Claude Code here first.'}</div>`;
315
+ return;
316
+ }
317
+ if(scope!=='all'){
318
+ el.innerHTML=state.sessions.map(sessionRow).join('');
319
+ return;
320
+ }
321
+ const groups=new Map();
322
+ for(const s of state.sessions){
323
+ const dir=s.dirShort??'?';
324
+ if(!groups.has(dir)) groups.set(dir,[]);
325
+ groups.get(dir).push(s);
326
+ }
327
+ const ordered=[...groups.entries()].sort((a,b)=>b[1][0].mtime-a[1][0].mtime);
328
+ el.innerHTML=ordered.map(([dir,list],i)=>`
329
+ <details class="fgroup" ${i===0?'open':''}>
330
+ <summary>
331
+ <span class="fold"></span>
332
+ <span class="fdir" title="${esc(list[0].dir??dir)}">${esc(dir)}</span>
333
+ <span class="fmeta">${list.length} SESSION${list.length===1?'':'S'} · ${ago(list[0].mtime).toUpperCase()}</span>
334
+ </summary>
335
+ ${list.map(sessionRow).join('')}
336
+ </details>`).join('');
337
+ el.querySelectorAll('.fgroup').forEach(d=>{
338
+ const set=()=>d.querySelector('.fold').textContent=d.open?'▾':'▸';
339
+ set(); d.addEventListener('toggle',set);
340
+ });
341
+ }
342
+
343
+ function baseName(p){ return p?String(p).split(/[\\/]/).filter(Boolean).pop():''; }
344
+ let mcpIndex=[];
345
+ function renderMcp(){
346
+ const el=$('#mcp'); if(!el) return;
347
+ const accts=(state.mcp||[]).filter(a=>a.servers.length);
348
+ if(!accts.length){
349
+ el.innerHTML=`<div class="empty">No account-specific MCP servers yet. Add one with <code>claude mcp add</code> in an account, then copy it here.</div>`;
350
+ return;
351
+ }
352
+ mcpIndex=[]; let html='';
353
+ for(const a of accts){
354
+ html+=`<div class="mgroup"><span style="color:${a.hue}">●</span> ${esc(a.name)}</div>`;
355
+ const targets=state.profiles.filter(p=>p.name!==a.name);
356
+ for(const s of a.servers){
357
+ const idx=mcpIndex.length; mcpIndex.push({from:a.name,name:s.name});
358
+ const tag=s.scope==='local'?`local · ${esc(baseName(s.project))}`:'user';
359
+ if(!targets.length){ html+=`<div class="srow"><span class="title">${esc(s.name)} <span class="mtag">${tag}</span></span><span class="act muted">add a second account to copy</span></div>`; continue; }
360
+ const localLabel=esc(s.scope==='local'?`local · ${baseName(s.project)}`:`local · ${baseName(state.cwd)}`);
361
+ html+=`<div class="srow">
362
+ <span class="title">${esc(s.name)} <span class="mtag">${tag}</span></span>
363
+ <span class="act">
364
+ <span class="to">→</span>
365
+ <select id="mcpto-${idx}">${targets.map(t=>`<option>${esc(t.name)}</option>`).join('')}</select>
366
+ <select id="mcpscope-${idx}"><option value="user">user · everywhere</option><option value="local"${s.scope==='local'?' selected':''}>${localLabel}</option></select>
367
+ <button class="board-btn mini" onclick="copyMcp(${idx})">Copy</button>
368
+ </span>
369
+ </div>`;
370
+ }
371
+ }
372
+ el.innerHTML=html;
373
+ }
374
+
375
+ // ── actions ────────────────────────────────────────────────────────────
376
+ async function api(p,body){ const r=await fetch(p,body?{method:'POST',body:JSON.stringify(body)}:{}); return r.json(); }
377
+ function toast(t){ const el=$('#toast'); el.textContent=t; el.classList.add('show'); clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('show'),3200); }
378
+ async function board(name){
379
+ const p=state?.profiles?.find(x=>x.name===name);
380
+ const login=!!p?.loggedOut;
381
+ const r=await api('/api/launch',{name,login});
382
+ toast(r.error??(login?`${name} is logged out — opened its sign-in in Windows Terminal`:`Opened ${name} in Windows Terminal`));
383
+ }
384
+ async function transfer(id){
385
+ const to=$('#sel-'+id.slice(0,8)).value;
386
+ const s=state.sessions.find(x=>x.id===id);
387
+ const r=await api('/api/move',{id,to});
388
+ if(r.error) return toast(r.error);
389
+ await api('/api/launch',{name:to,resume:id,dir:s?.dir});
390
+ toast(`Transferred ${id.slice(0,8)} → ${to} · opened in Windows Terminal`);
391
+ }
392
+ async function copyMcp(idx){
393
+ const m=mcpIndex[idx]; if(!m) return;
394
+ const to=$('#mcpto-'+idx).value, scope=$('#mcpscope-'+idx).value;
395
+ const r=await api('/api/mcp/copy',{from:m.from,to,name:m.name,scope});
396
+ if(r.error) return toast(r.error);
397
+ const where=r.scope==='local'?`local · ${baseName(r.project)}`:'user';
398
+ toast(`${r.replaced?'Replaced':'Copied'} ${m.name} → ${to} (${where})`);
399
+ await load();
400
+ }
401
+ async function refreshUsage(btn){
402
+ btn.disabled=true; btn.textContent='Refreshing…';
403
+ try{ state=await api('/api/refresh',{scope}); renderBoard(); renderSessions(); renderMcp(); stamp(); }
404
+ finally{ btn.disabled=false; btn.textContent='Refresh usage'; }
405
+ }
406
+ async function runDoctor(){
407
+ $('#doctor').innerHTML='<div class="empty">Running checks…</div>';
408
+ const d=await api('/api/doctor');
409
+ const lamp={ok:['●','ok'],warn:['▲','warn'],err:['■','err']};
410
+ let last=''; let html='';
411
+ for(const e of d.entries){
412
+ if(e.group&&e.group!==last) html+=`<div class="dgroup">${e.group.toUpperCase()}</div>`;
413
+ last=e.group;
414
+ const [g,c]=lamp[e.level];
415
+ html+=`<div class="drow"><span class="lamp ${c}">${g}</span><span>${e.msg.replace(/</g,'&lt;')}</span></div>`;
416
+ }
417
+ $('#doctor').innerHTML=html+`<div style="margin-top:12px"><button class="board-btn mini" onclick="runDoctor()">Run again</button></div>`;
418
+ }
419
+ function stamp(){ $('#updated').textContent='updated '+new Date().toTimeString().slice(0,5); $('#cwd').textContent=state.cwd; }
420
+
421
+ async function load(force){
422
+ // Fetch live usage the moment the board opens; polls read the cache.
423
+ state = force ? await api('/api/refresh',{scope}) : await api('/api/state?scope='+scope);
424
+ renderBoard(); renderSessions(); renderMcp(); stamp();
425
+ }
426
+ load(true);
427
+ setInterval(load, 20000);
428
+ document.addEventListener('visibilitychange',()=>{ if(!document.hidden) load(); });
429
+ </script>
430
+ </body>
431
+ </html>