arp-tui 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,2333 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { parseArgs as nodeParseArgs } from "util";
5
+ import { render } from "ink";
6
+
7
+ // src/app.tsx
8
+ import { Box as Box6, Text as Text8, useApp, useInput, useStdin } from "ink";
9
+ import { useEffect, useSyncExternalStore } from "react";
10
+
11
+ // src/state/store.ts
12
+ import { randomUUID } from "crypto";
13
+
14
+ // src/config.ts
15
+ import fs2 from "fs";
16
+ import os from "os";
17
+ import path2 from "path";
18
+
19
+ // src/util/fsSafe.ts
20
+ import fs from "fs";
21
+ import path from "path";
22
+ function ensureDir0700(dir) {
23
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
24
+ }
25
+ function writeFileAtomic0600(file, data) {
26
+ const dir = path.dirname(file);
27
+ ensureDir0700(dir);
28
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
29
+ fs.writeFileSync(tmp, data, { mode: 384 });
30
+ try {
31
+ fs.renameSync(tmp, file);
32
+ } catch (err) {
33
+ try {
34
+ fs.unlinkSync(tmp);
35
+ } catch {
36
+ }
37
+ throw err;
38
+ }
39
+ }
40
+ function readJsonTolerant(file) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(file, "utf8"));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function loosePermsError(p, expected) {
48
+ if (process.platform === "win32") return null;
49
+ let st;
50
+ try {
51
+ st = fs.statSync(p);
52
+ } catch {
53
+ return null;
54
+ }
55
+ const mode = st.mode & 511;
56
+ if ((mode & 63) !== 0) {
57
+ const want = expected === "dir" ? "700" : "600";
58
+ return `${p} has loose permissions (0${mode.toString(8)}). Fix: chmod ${want} ${p}`;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // src/config.ts
64
+ var CONFIG_DIR = path2.join(os.homedir(), ".arp-tui");
65
+ var CONFIG_FILE = path2.join(CONFIG_DIR, "config.json");
66
+ var CREDENTIALS_FILE = path2.join(CONFIG_DIR, "credentials.json");
67
+ var ConfigError = class extends Error {
68
+ constructor(message) {
69
+ super(message);
70
+ this.name = "ConfigError";
71
+ }
72
+ };
73
+ var DEV_PROFILE = {
74
+ relayUrl: "https://arp-relay-dev.up.railway.app",
75
+ webUrl: "https://arp-web-dev.up.railway.app",
76
+ clerkFapiUrl: "https://generous-fish-48.clerk.accounts.dev",
77
+ clerkJwtTemplate: "arp",
78
+ clerkOAuthClientId: "6WIfOfpIZ1VfuqoF"
79
+ };
80
+ var DEFAULT_PROFILE_NAME = "dev";
81
+ var PROFILE_EXAMPLE = ` "profiles": {
82
+ "myinstance": {
83
+ "relayUrl": "https://relay.example.com",
84
+ "webUrl": "https://app.example.com",
85
+ "clerkFapiUrl": "https://your-instance.clerk.accounts.dev",
86
+ "clerkJwtTemplate": "arp"
87
+ }
88
+ }`;
89
+ function stripSlash(url) {
90
+ return url.replace(/\/+$/, "");
91
+ }
92
+ function requireUrlField(name, raw, field) {
93
+ const v = raw[field];
94
+ if (typeof v !== "string" || v.length === 0) {
95
+ throw new ConfigError(
96
+ `profile "${name}" in ${CONFIG_FILE} is missing required field "${field}".
97
+ Each profile names one ARP instance completely \u2014 the TUI never fills gaps with another instance's coordinates.
98
+ Expected shape:
99
+ ${PROFILE_EXAMPLE}`
100
+ );
101
+ }
102
+ return stripSlash(v);
103
+ }
104
+ function normalizeProfile(name, raw) {
105
+ if (typeof raw !== "object" || raw === null) {
106
+ throw new ConfigError(`profile "${name}" in ${CONFIG_FILE} must be an object.
107
+ Expected shape:
108
+ ${PROFILE_EXAMPLE}`);
109
+ }
110
+ const r = raw;
111
+ const relayUrl = requireUrlField(name, r, "relayUrl");
112
+ const clerkOAuthClientId = typeof r["clerkOAuthClientId"] === "string" && r["clerkOAuthClientId"] ? r["clerkOAuthClientId"] : void 0;
113
+ if (relayUrl === DEV_PROFILE.relayUrl) {
114
+ return {
115
+ relayUrl,
116
+ webUrl: typeof r["webUrl"] === "string" && r["webUrl"] ? stripSlash(r["webUrl"]) : DEV_PROFILE.webUrl,
117
+ clerkFapiUrl: typeof r["clerkFapiUrl"] === "string" && r["clerkFapiUrl"] ? stripSlash(r["clerkFapiUrl"]) : DEV_PROFILE.clerkFapiUrl,
118
+ clerkJwtTemplate: typeof r["clerkJwtTemplate"] === "string" && r["clerkJwtTemplate"] ? r["clerkJwtTemplate"] : DEV_PROFILE.clerkJwtTemplate,
119
+ clerkOAuthClientId: clerkOAuthClientId ?? DEV_PROFILE.clerkOAuthClientId
120
+ };
121
+ }
122
+ return {
123
+ relayUrl,
124
+ webUrl: requireUrlField(name, r, "webUrl"),
125
+ clerkFapiUrl: requireUrlField(name, r, "clerkFapiUrl"),
126
+ // template name is a product convention shared across instances; default documented in README
127
+ clerkJwtTemplate: typeof r["clerkJwtTemplate"] === "string" && r["clerkJwtTemplate"] ? r["clerkJwtTemplate"] : DEV_PROFILE.clerkJwtTemplate,
128
+ clerkOAuthClientId
129
+ };
130
+ }
131
+ function readConfigRaw() {
132
+ if (!fs2.existsSync(CONFIG_FILE)) return null;
133
+ const text = fs2.readFileSync(CONFIG_FILE, "utf8");
134
+ if (text.trim().length === 0) return null;
135
+ try {
136
+ const parsed = JSON.parse(text);
137
+ if (typeof parsed !== "object" || parsed === null) throw new Error("not an object");
138
+ return parsed;
139
+ } catch {
140
+ throw new ConfigError(
141
+ `${CONFIG_FILE} is not valid JSON \u2014 refusing to start (a rewrite would destroy your settings).
142
+ Fix or delete the file and re-run.`
143
+ );
144
+ }
145
+ }
146
+ function loadConfig() {
147
+ const cfg = readConfigRaw() ?? {};
148
+ const profiles = /* @__PURE__ */ Object.create(null);
149
+ const rawProfiles = cfg["profiles"];
150
+ if (rawProfiles !== void 0) {
151
+ if (typeof rawProfiles !== "object" || rawProfiles === null) {
152
+ throw new ConfigError(`"profiles" in ${CONFIG_FILE} must be an object.
153
+ Expected shape:
154
+ ${PROFILE_EXAMPLE}`);
155
+ }
156
+ for (const [name, raw] of Object.entries(rawProfiles)) {
157
+ profiles[name] = normalizeProfile(name, raw);
158
+ }
159
+ }
160
+ if (Object.keys(profiles).length === 0) {
161
+ const legacyRelay = typeof cfg["relayUrl"] === "string" ? stripSlash(cfg["relayUrl"]) : null;
162
+ if (legacyRelay && legacyRelay !== DEV_PROFILE.relayUrl) {
163
+ throw new ConfigError(
164
+ `${CONFIG_FILE} has a legacy "relayUrl" (${legacyRelay}) that is not the known dev instance.
165
+ The TUI cannot invent the web-app/Clerk coordinates for it. Add a full profile:
166
+ ${PROFILE_EXAMPLE}
167
+ then set "profile": "myinstance" and remove the top-level "relayUrl".`
168
+ );
169
+ }
170
+ profiles[DEFAULT_PROFILE_NAME] = { ...DEV_PROFILE };
171
+ }
172
+ const requested = typeof cfg["profile"] === "string" ? cfg["profile"] : DEFAULT_PROFILE_NAME;
173
+ if (!Object.hasOwn(profiles, requested)) {
174
+ throw new ConfigError(
175
+ `active profile "${requested}" (set in ${CONFIG_FILE}) does not exist.
176
+ Profiles defined: ${Object.keys(profiles).join(", ")}. Fix "profile" or add the missing entry \u2014 the TUI never falls back to a different instance.`
177
+ );
178
+ }
179
+ return {
180
+ profile: requested,
181
+ profiles,
182
+ lastChannelId: typeof cfg["lastChannelId"] === "string" ? cfg["lastChannelId"] : void 0,
183
+ lastSeq: cfg["lastSeq"] && typeof cfg["lastSeq"] === "object" ? cfg["lastSeq"] : {}
184
+ };
185
+ }
186
+ function resolveProfile(cfg, overrides) {
187
+ const name = overrides?.profile ?? process.env["ARP_TUI_PROFILE"] ?? cfg.profile;
188
+ if (!Object.hasOwn(cfg.profiles, name)) {
189
+ const known = Object.keys(cfg.profiles).join(", ") || "(none)";
190
+ throw new ConfigError(`unknown profile "${name}" \u2014 profiles in ${CONFIG_FILE}: ${known}`);
191
+ }
192
+ const base2 = cfg.profiles[name];
193
+ const relayOverride = overrides?.relayUrl ?? process.env["ARP_TUI_RELAY"];
194
+ const fapiOverride = process.env["ARP_TUI_CLERK_FAPI"];
195
+ return {
196
+ name,
197
+ base: base2,
198
+ instance: {
199
+ ...base2,
200
+ relayUrl: stripSlash(relayOverride ?? base2.relayUrl),
201
+ clerkFapiUrl: stripSlash(fapiOverride ?? base2.clerkFapiUrl)
202
+ }
203
+ };
204
+ }
205
+ function saveConfig(cfg) {
206
+ writeFileAtomic0600(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
207
+ }
208
+ function assertStorePerms() {
209
+ ensureDir0700(CONFIG_DIR);
210
+ const problems = [
211
+ loosePermsError(CONFIG_DIR, "dir"),
212
+ loosePermsError(CONFIG_FILE, "file"),
213
+ loosePermsError(CREDENTIALS_FILE, "file")
214
+ ].filter((p) => p !== null);
215
+ if (problems.length > 0) {
216
+ throw new Error(
217
+ "refusing to start \u2014 credential store permissions are loose:\n " + problems.join("\n ")
218
+ );
219
+ }
220
+ }
221
+
222
+ // src/api/oauth.ts
223
+ import { createHash, randomBytes } from "crypto";
224
+ import http from "http";
225
+ import { spawn } from "child_process";
226
+ var OAUTH_SCOPES = "openid profile email offline_access user:org:read";
227
+ var REFRESH_EARLY_MS = 3e4;
228
+ var CALLBACK_TIMEOUT_MS = 5 * 6e4;
229
+ var TOKEN_TIMEOUT_MS = 15e3;
230
+ var CALLBACK_PATH = "/callback";
231
+ var OAuthError = class extends Error {
232
+ constructor(message) {
233
+ super(message);
234
+ this.name = "OAuthError";
235
+ }
236
+ };
237
+ function oauthEndpoints(clerkFapiUrl) {
238
+ const base2 = clerkFapiUrl.replace(/\/+$/, "");
239
+ return {
240
+ authorize: `${base2}/oauth/authorize`,
241
+ token: `${base2}/oauth/token`,
242
+ userinfo: `${base2}/oauth/userinfo`,
243
+ discovery: `${base2}/.well-known/openid-configuration`
244
+ };
245
+ }
246
+ function requireOAuthClientId(instance2) {
247
+ if (!instance2.clerkOAuthClientId) {
248
+ throw new OAuthError(
249
+ "this profile has no clerkOAuthClientId \u2014 add it to the profile in ~/.arp-tui/config.json (the OAuth application registered in the instance's Clerk dashboard)."
250
+ );
251
+ }
252
+ return instance2.clerkOAuthClientId;
253
+ }
254
+ function generateVerifier() {
255
+ return randomBytes(32).toString("base64url");
256
+ }
257
+ function pkceChallenge(verifier) {
258
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
259
+ }
260
+ function buildAuthorizeUrl(opts) {
261
+ const qs = new URLSearchParams({
262
+ client_id: opts.clientId,
263
+ response_type: "code",
264
+ scope: OAUTH_SCOPES,
265
+ redirect_uri: opts.redirectUri,
266
+ state: opts.state,
267
+ code_challenge: opts.challenge,
268
+ code_challenge_method: "S256"
269
+ });
270
+ return `${opts.endpoints.authorize}?${qs.toString()}`;
271
+ }
272
+ function parseCallback(query, expectedState) {
273
+ if (query.get("state") !== expectedState) {
274
+ throw new OAuthError("state mismatch on the OAuth callback \u2014 rejecting the code (possible CSRF)");
275
+ }
276
+ const err = query.get("error");
277
+ if (err) {
278
+ const desc = query.get("error_description");
279
+ throw new OAuthError(`authorization failed: ${err}${desc ? ` \u2014 ${desc}` : ""}`);
280
+ }
281
+ const code = query.get("code");
282
+ if (!code) throw new OAuthError("OAuth callback carried no authorization code");
283
+ return code;
284
+ }
285
+ function startLoopbackServer(expectedState, timeoutMs = CALLBACK_TIMEOUT_MS) {
286
+ return new Promise((resolveServer, rejectServer) => {
287
+ let settleCode;
288
+ const code = new Promise((resolve, reject) => {
289
+ settleCode = { resolve, reject };
290
+ });
291
+ code.catch(() => {
292
+ });
293
+ const page = (tone, title, body) => {
294
+ const icon = tone === "ok" ? "\u2713" : tone === "warn" ? "!" : "\u2715";
295
+ const accent = tone === "ok" ? "#34d399" : tone === "warn" ? "#fbbf24" : "#f87171";
296
+ return `<!doctype html><html><head><meta charset="utf-8"><title>arp-tui \u2014 ${title}</title></head>
297
+ <body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b1220;font-family:system-ui,-apple-system,Segoe UI,sans-serif">
298
+ <div style="max-width:26rem;margin:1rem;padding:2.5rem 3rem;border-radius:12px;background:#111a2e;border:1px solid #223052;text-align:center;color:#e6ebf5">
299
+ <div style="width:3rem;height:3rem;margin:0 auto 1rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:700;color:#0b1220;background:${accent}">${icon}</div>
300
+ <h1 style="margin:0 0 .5rem;font-size:1.25rem;font-weight:600">${title}</h1>
301
+ <p style="margin:0;color:#93a1bd;line-height:1.55">${body}</p>
302
+ <p style="margin:1.5rem 0 0;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:#4b5a7a">Agent Relay Protocol \xB7 arp-tui</p>
303
+ </div></body></html>`;
304
+ };
305
+ const server = http.createServer((req, res) => {
306
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
307
+ if (url.pathname !== CALLBACK_PATH) {
308
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("not found");
309
+ return;
310
+ }
311
+ if (url.searchParams.get("state") !== expectedState) {
312
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }).end(page("warn", "Rejected a mismatched callback", "The login in the terminal is still waiting for the real sign-in redirect."));
313
+ return;
314
+ }
315
+ clearTimeout(timer);
316
+ server.close();
317
+ try {
318
+ const authCode = parseCallback(url.searchParams, expectedState);
319
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(page("ok", "Signed in", "You're all set \u2014 close this tab and return to the terminal."));
320
+ settleCode.resolve(authCode);
321
+ } catch (err) {
322
+ const outcome = err instanceof Error ? err : new Error(String(err));
323
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }).end(page("error", "Login failed", outcome.message.replace(/</g, "&lt;")));
324
+ settleCode.reject(outcome);
325
+ }
326
+ });
327
+ const timer = setTimeout(() => {
328
+ server.close();
329
+ settleCode.reject(new OAuthError(`no browser callback within ${Math.round(timeoutMs / 1e3)}s \u2014 aborting login`));
330
+ }, timeoutMs);
331
+ timer.unref?.();
332
+ server.on("error", (err) => {
333
+ clearTimeout(timer);
334
+ rejectServer(new OAuthError(`cannot bind loopback callback server: ${err.message}`));
335
+ });
336
+ server.listen(0, "127.0.0.1", () => {
337
+ const { port } = server.address();
338
+ resolveServer({
339
+ redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
340
+ code,
341
+ close: () => {
342
+ clearTimeout(timer);
343
+ server.close();
344
+ }
345
+ });
346
+ });
347
+ });
348
+ }
349
+ async function tokenRequest(endpoint, params) {
350
+ let res;
351
+ try {
352
+ res = await fetch(endpoint, {
353
+ method: "POST",
354
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
355
+ body: new URLSearchParams(params).toString(),
356
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS)
357
+ });
358
+ } catch (err) {
359
+ throw new OAuthError(`token endpoint unreachable: ${err instanceof Error ? err.message : String(err)}`);
360
+ }
361
+ let data = null;
362
+ try {
363
+ data = await res.json();
364
+ } catch {
365
+ }
366
+ if (!res.ok || typeof data?.access_token !== "string") {
367
+ const msg = data?.error_description ?? data?.error ?? `HTTP ${res.status}`;
368
+ throw new OAuthError(`token request failed: ${msg}`);
369
+ }
370
+ return {
371
+ accessToken: data.access_token,
372
+ refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
373
+ idToken: typeof data.id_token === "string" ? data.id_token : void 0,
374
+ expiresAt: typeof data.expires_in === "number" ? new Date(Date.now() + data.expires_in * 1e3).toISOString() : void 0,
375
+ scope: typeof data.scope === "string" ? data.scope : void 0
376
+ };
377
+ }
378
+ function exchangeCode(opts) {
379
+ return tokenRequest(opts.endpoints.token, {
380
+ grant_type: "authorization_code",
381
+ client_id: opts.clientId,
382
+ code: opts.code,
383
+ code_verifier: opts.verifier,
384
+ redirect_uri: opts.redirectUri
385
+ });
386
+ }
387
+ function refreshGrant(opts) {
388
+ return tokenRequest(opts.endpoints.token, {
389
+ grant_type: "refresh_token",
390
+ client_id: opts.clientId,
391
+ refresh_token: opts.refreshToken
392
+ });
393
+ }
394
+ function needsRefresh(expiresAt, now = Date.now()) {
395
+ if (!expiresAt) return true;
396
+ const t = Date.parse(expiresAt);
397
+ if (!Number.isFinite(t)) return true;
398
+ return now >= t - REFRESH_EARLY_MS;
399
+ }
400
+ function isExpired(expiresAt, now = Date.now()) {
401
+ if (!expiresAt) return true;
402
+ const t = Date.parse(expiresAt);
403
+ if (!Number.isFinite(t)) return true;
404
+ return now >= t;
405
+ }
406
+ async function fetchUserInfo(endpoints, accessToken) {
407
+ try {
408
+ const res = await fetch(endpoints.userinfo, {
409
+ headers: { Authorization: `Bearer ${accessToken}` },
410
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS)
411
+ });
412
+ if (!res.ok) return null;
413
+ const data = await res.json();
414
+ if (typeof data?.sub !== "string") return null;
415
+ return {
416
+ sub: data.sub,
417
+ email: typeof data.email === "string" ? data.email : void 0,
418
+ name: typeof data.name === "string" ? data.name : void 0
419
+ };
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+ async function discoverRevocationEndpoint(endpoints) {
425
+ try {
426
+ const res = await fetch(endpoints.discovery, { signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS) });
427
+ if (!res.ok) return null;
428
+ const data = await res.json();
429
+ return typeof data?.revocation_endpoint === "string" ? data.revocation_endpoint : null;
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+ async function revokeToken(revocationEndpoint, clientId, token, hint) {
435
+ try {
436
+ const res = await fetch(revocationEndpoint, {
437
+ method: "POST",
438
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
439
+ body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }).toString(),
440
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS)
441
+ });
442
+ return res.ok;
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+ function browserSpawnSpec(url, platform = process.platform) {
448
+ if (platform === "darwin") return { cmd: "open", args: [url] };
449
+ if (platform === "win32") {
450
+ const safe = url.replace(/"/g, "%22");
451
+ return { cmd: "cmd", args: ["/c", `start "" "${safe}"`], windowsVerbatimArguments: true };
452
+ }
453
+ return { cmd: "xdg-open", args: [url] };
454
+ }
455
+ function openBrowser(url) {
456
+ const spec = browserSpawnSpec(url);
457
+ try {
458
+ spawn(spec.cmd, spec.args, {
459
+ stdio: "ignore",
460
+ detached: true,
461
+ windowsVerbatimArguments: spec.windowsVerbatimArguments
462
+ }).unref();
463
+ } catch {
464
+ }
465
+ }
466
+ async function runLoginFlow(instance2, log = console.error) {
467
+ const clientId = requireOAuthClientId(instance2);
468
+ const endpoints = oauthEndpoints(instance2.clerkFapiUrl);
469
+ const verifier = generateVerifier();
470
+ const challenge = pkceChallenge(verifier);
471
+ const state = randomBytes(16).toString("base64url");
472
+ const server = await startLoopbackServer(state);
473
+ try {
474
+ const authorizeUrl = buildAuthorizeUrl({
475
+ endpoints,
476
+ clientId,
477
+ redirectUri: server.redirectUri,
478
+ state,
479
+ challenge
480
+ });
481
+ log(`Opening your browser to sign in (listening on ${server.redirectUri})\u2026`);
482
+ log(`If it doesn't open, visit:
483
+ ${authorizeUrl}`);
484
+ openBrowser(authorizeUrl);
485
+ const code = await server.code;
486
+ log("Callback received \u2014 exchanging the code\u2026");
487
+ const tokens = await exchangeCode({
488
+ endpoints,
489
+ clientId,
490
+ code,
491
+ verifier,
492
+ redirectUri: server.redirectUri
493
+ });
494
+ const user = await fetchUserInfo(endpoints, tokens.accessToken);
495
+ return { tokens, user };
496
+ } finally {
497
+ server.close();
498
+ }
499
+ }
500
+
501
+ // src/api/auth.ts
502
+ function isTombstone(v) {
503
+ return !!v && typeof v === "object" && v.loggedOut === true;
504
+ }
505
+ function loadCredentialsMap() {
506
+ const raw = readJsonTolerant(CREDENTIALS_FILE);
507
+ if (!raw || typeof raw !== "object") return {};
508
+ if (typeof raw["token"] === "string" && typeof raw["relayUrl"] === "string") {
509
+ const legacy = raw;
510
+ return { [legacy.relayUrl]: legacy };
511
+ }
512
+ const map = {};
513
+ for (const [key, val] of Object.entries(raw)) {
514
+ if (val && typeof val === "object" && (typeof val.token === "string" || isTombstone(val))) {
515
+ map[key] = val;
516
+ }
517
+ }
518
+ return map;
519
+ }
520
+ function writeCredentialsMap(map) {
521
+ writeFileAtomic0600(CREDENTIALS_FILE, JSON.stringify(map, null, 2) + "\n");
522
+ }
523
+ function updateCredentials(relayUrl, update) {
524
+ const map = loadCredentialsMap();
525
+ const raw = Object.hasOwn(map, relayUrl) ? map[relayUrl] : void 0;
526
+ const onDisk = raw !== void 0 && !isTombstone(raw) ? raw : null;
527
+ const next = update(onDisk);
528
+ if (next === onDisk) return next;
529
+ if (next === null) {
530
+ if (onDisk === null) return null;
531
+ map[relayUrl] = { loggedOut: true, at: (/* @__PURE__ */ new Date()).toISOString() };
532
+ } else {
533
+ map[relayUrl] = next;
534
+ }
535
+ writeCredentialsMap(map);
536
+ return next;
537
+ }
538
+ function mergePastedToken(onDisk, pasted) {
539
+ if (!onDisk) return pasted;
540
+ if (onDisk.sub && pasted.sub && onDisk.sub !== pasted.sub) return pasted;
541
+ if (onDisk.sub && pasted.sub) {
542
+ return {
543
+ ...onDisk,
544
+ token: pasted.token,
545
+ tokenType: onDisk.refreshToken ? "oauth" : pasted.tokenType,
546
+ obtainedAt: pasted.obtainedAt,
547
+ expiresAt: pasted.expiresAt
548
+ };
549
+ }
550
+ return {
551
+ ...pasted,
552
+ clerkClientToken: pasted.clerkClientToken ?? onDisk.clerkClientToken,
553
+ refreshToken: pasted.refreshToken ?? onDisk.refreshToken,
554
+ clerkFapiUrl: pasted.clerkFapiUrl ?? onDisk.clerkFapiUrl,
555
+ sub: pasted.sub ?? onDisk.sub,
556
+ tokenType: pasted.refreshToken ?? onDisk.refreshToken ? "oauth" : pasted.tokenType
557
+ };
558
+ }
559
+ function mergeRotation(onDisk, fields, consumed) {
560
+ if (!onDisk?.refreshToken || onDisk.refreshToken !== consumed) return onDisk;
561
+ return { ...onDisk, ...fields };
562
+ }
563
+ function chooseRefreshToken(args2) {
564
+ const { memoryRt, diskRt, memorySub, diskSub, knownNewerInMemory } = args2;
565
+ const memNewerThanDisk = knownNewerInMemory && diskRt !== memoryRt;
566
+ if (memNewerThanDisk) {
567
+ if (diskRt === null && diskSub && memorySub && diskSub !== memorySub) return null;
568
+ return memoryRt;
569
+ }
570
+ const sameIdentity = diskRt === memoryRt || !!diskSub && diskSub === memorySub;
571
+ return diskRt && sameIdentity ? diskRt : null;
572
+ }
573
+ function boundToFapi(recordedFapiUrl, fapiUrl) {
574
+ return !!recordedFapiUrl && recordedFapiUrl === fapiUrl;
575
+ }
576
+ function jwtClaims(token) {
577
+ try {
578
+ const payload = token.split(".")[1];
579
+ if (!payload) return null;
580
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+ function jwtExpiry(token) {
586
+ const exp = jwtClaims(token)?.["exp"];
587
+ return typeof exp === "number" ? new Date(exp * 1e3).toISOString() : void 0;
588
+ }
589
+ function jwtSub(token) {
590
+ const sub = jwtClaims(token)?.["sub"];
591
+ return typeof sub === "string" && sub ? sub : void 0;
592
+ }
593
+ function classifyToken(token) {
594
+ if (token.startsWith("arp_ak_")) {
595
+ throw new Error(
596
+ "that is an AGENT key (arp_ak_). The TUI authenticates as the HUMAN only \u2014 paste a Clerk session JWT instead (see README)."
597
+ );
598
+ }
599
+ return token.startsWith("arp_pt_") ? "pat" : "dev-jwt";
600
+ }
601
+ function makeCredentials(relayUrl, token, extra) {
602
+ return {
603
+ relayUrl,
604
+ token,
605
+ tokenType: classifyToken(token),
606
+ obtainedAt: (/* @__PURE__ */ new Date()).toISOString(),
607
+ expiresAt: jwtExpiry(token),
608
+ sub: jwtSub(token),
609
+ ...extra
610
+ };
611
+ }
612
+ function loadStoredCredentials(relayUrl) {
613
+ const map = loadCredentialsMap();
614
+ const c = Object.hasOwn(map, relayUrl) ? map[relayUrl] : null;
615
+ if (!c || isTombstone(c) || typeof c.token !== "string") return null;
616
+ return c;
617
+ }
618
+ function resolveCredentials(instance2, flagToken) {
619
+ const relayUrl = instance2.relayUrl;
620
+ const stored = loadStoredCredentials(relayUrl);
621
+ const storedClientToken = stored?.clerkClientToken && boundToFapi(stored.clerkFapiUrl, instance2.clerkFapiUrl) ? stored.clerkClientToken : void 0;
622
+ const clerkClientToken = process.env["ARP_TUI_CLERK_CLIENT_TOKEN"] ?? storedClientToken;
623
+ const clerkExtra = clerkClientToken ? { clerkClientToken, clerkFapiUrl: instance2.clerkFapiUrl } : {};
624
+ if (flagToken) return makeCredentials(relayUrl, flagToken, clerkExtra);
625
+ const envToken = process.env["ARP_TUI_TOKEN"];
626
+ if (envToken) return makeCredentials(relayUrl, envToken, clerkExtra);
627
+ if (stored) {
628
+ const oauthBound = !!stored.refreshToken && boundToFapi(stored.clerkFapiUrl, instance2.clerkFapiUrl);
629
+ return {
630
+ ...stored,
631
+ clerkClientToken,
632
+ refreshToken: oauthBound ? stored.refreshToken : void 0,
633
+ clerkFapiUrl: clerkClientToken || oauthBound ? instance2.clerkFapiUrl : void 0
634
+ };
635
+ }
636
+ if (clerkClientToken) {
637
+ return {
638
+ relayUrl,
639
+ token: "",
640
+ tokenType: "dev-jwt",
641
+ obtainedAt: (/* @__PURE__ */ new Date()).toISOString(),
642
+ ...clerkExtra
643
+ };
644
+ }
645
+ return null;
646
+ }
647
+ function tombstoneCredentials(relayUrl, expected) {
648
+ let gone = false;
649
+ updateCredentials(relayUrl, (onDisk) => {
650
+ if (!onDisk) {
651
+ gone = true;
652
+ return onDisk;
653
+ }
654
+ if (onDisk.token !== expected.token || onDisk.refreshToken !== expected.refreshToken) {
655
+ return onDisk;
656
+ }
657
+ gone = true;
658
+ return null;
659
+ });
660
+ return gone;
661
+ }
662
+ async function login(instance2, log = console.error) {
663
+ const { tokens, user } = await runLoginFlow(instance2, log);
664
+ const prior = loadStoredCredentials(instance2.relayUrl);
665
+ if (prior?.refreshToken && prior.clerkFapiUrl && prior.refreshToken !== tokens.refreshToken) {
666
+ try {
667
+ const revocation = await discoverRevocationEndpoint(oauthEndpoints(prior.clerkFapiUrl));
668
+ const ok = !!revocation && await revokeToken(revocation, requireOAuthClientId(instance2), prior.refreshToken, "refresh_token");
669
+ log(
670
+ ok ? "revoked the previous OAuth grant (replaced by this login)" : "could not revoke the previous OAuth grant \u2014 it stays live at Clerk until it expires"
671
+ );
672
+ } catch {
673
+ log("could not revoke the previous OAuth grant \u2014 it stays live at Clerk until it expires");
674
+ }
675
+ }
676
+ const sub = user?.sub ?? jwtSub(tokens.idToken ?? "") ?? jwtSub(tokens.accessToken);
677
+ const credentials2 = updateCredentials(
678
+ instance2.relayUrl,
679
+ (onDisk) => makeCredentials(instance2.relayUrl, tokens.accessToken, {
680
+ tokenType: "oauth",
681
+ expiresAt: tokens.expiresAt ?? jwtExpiry(tokens.accessToken),
682
+ sub,
683
+ refreshToken: tokens.refreshToken,
684
+ clerkClientToken: boundToFapi(onDisk?.clerkFapiUrl, instance2.clerkFapiUrl) ? onDisk?.clerkClientToken : void 0,
685
+ clerkFapiUrl: instance2.clerkFapiUrl
686
+ })
687
+ );
688
+ return { credentials: credentials2, email: user?.email };
689
+ }
690
+ async function logout(instance2, log = console.error) {
691
+ const stored = loadStoredCredentials(instance2.relayUrl);
692
+ if (!stored) {
693
+ log(`no stored credentials for ${instance2.relayUrl} \u2014 nothing to do`);
694
+ return false;
695
+ }
696
+ if (stored.refreshToken && stored.clerkFapiUrl) {
697
+ let failure = "";
698
+ try {
699
+ const clientId = requireOAuthClientId(instance2);
700
+ const revocation = await discoverRevocationEndpoint(oauthEndpoints(stored.clerkFapiUrl));
701
+ if (!revocation) {
702
+ failure = "no revocation endpoint reachable/advertised";
703
+ } else if (await revokeToken(revocation, clientId, stored.refreshToken, "refresh_token")) {
704
+ log("revoked the OAuth grant with Clerk");
705
+ } else {
706
+ failure = "the revocation request failed";
707
+ }
708
+ } catch (err) {
709
+ failure = err instanceof Error ? err.message : String(err);
710
+ }
711
+ if (failure) {
712
+ log(
713
+ `NOT logging out: ${failure} \u2014 the refresh token is still live at Clerk. Retry \`arp-tui logout\` when it's reachable, or delete ~/.arp-tui/credentials.json to discard the tokens unrevoked.`
714
+ );
715
+ return false;
716
+ }
717
+ }
718
+ if (tombstoneCredentials(instance2.relayUrl, stored)) {
719
+ log(`logged out of ${instance2.relayUrl}`);
720
+ } else {
721
+ log("a newer login/paste landed during logout \u2014 keeping it (the old grant was revoked)");
722
+ }
723
+ return true;
724
+ }
725
+
726
+ // src/api/clerkRefresh.ts
727
+ var DEFAULT_CLERK_FAPI = "https://generous-fish-48.clerk.accounts.dev";
728
+ var DEFAULT_JWT_TEMPLATE = "arp";
729
+ var CACHE_TTL_MS = 45e3;
730
+ var FAPI_TIMEOUT_MS = 1e4;
731
+ var ClerkRefreshError = class extends Error {
732
+ constructor(message) {
733
+ super(message);
734
+ this.name = "ClerkRefreshError";
735
+ }
736
+ };
737
+ function createClerkTokenSupplier(clientToken, opts = {}) {
738
+ const fapi = (opts.fapiUrl ?? process.env["ARP_TUI_CLERK_FAPI"] ?? DEFAULT_CLERK_FAPI).replace(/\/$/, "");
739
+ const template = opts.template ?? DEFAULT_JWT_TEMPLATE;
740
+ let cached = null;
741
+ let sessionId = null;
742
+ async function fapiFetch(path3, method) {
743
+ const res = await fetch(`${fapi}${path3}`, {
744
+ method,
745
+ headers: { Authorization: clientToken },
746
+ signal: AbortSignal.timeout(FAPI_TIMEOUT_MS)
747
+ });
748
+ let data = null;
749
+ try {
750
+ data = await res.json();
751
+ } catch {
752
+ }
753
+ if (!res.ok) {
754
+ const msg = data?.errors?.[0]?.message ?? `HTTP ${res.status}`;
755
+ throw new ClerkRefreshError(`Clerk FAPI ${method} ${path3}: ${msg}`);
756
+ }
757
+ return data;
758
+ }
759
+ return async function getSessionJwt() {
760
+ if (cached && Date.now() - cached.mintedAt < CACHE_TTL_MS) return cached.jwt;
761
+ if (!sessionId) {
762
+ const data2 = await fapiFetch("/v1/client?_is_native=1", "GET");
763
+ const client = data2?.response ?? data2?.client ?? data2;
764
+ const sid = client?.last_active_session_id ?? client?.sessions?.[0]?.id ?? null;
765
+ if (!sid) {
766
+ throw new ClerkRefreshError(
767
+ "Clerk client token has no active session (expired or wrong cookie value) \u2014 paste a session JWT instead"
768
+ );
769
+ }
770
+ sessionId = sid;
771
+ }
772
+ const data = await fapiFetch(
773
+ `/v1/client/sessions/${sessionId}/tokens/${template}?_is_native=1`,
774
+ "POST"
775
+ );
776
+ const jwt = data?.jwt ?? data?.response?.jwt;
777
+ if (!jwt) {
778
+ sessionId = null;
779
+ throw new ClerkRefreshError("Clerk FAPI returned no jwt for the session token request");
780
+ }
781
+ cached = { jwt, mintedAt: Date.now() };
782
+ return jwt;
783
+ };
784
+ }
785
+
786
+ // src/api/types.ts
787
+ function normalizeMember(raw, prev) {
788
+ const id = String(raw?.id ?? raw?.memberId ?? raw?.userId ?? prev?.id ?? "");
789
+ const rawType = raw?.type ?? raw?.memberType;
790
+ const status = raw?.status === "active" || raw?.status === "online" || raw?.isOnline === true ? "online" : raw?.status === "away" ? "away" : raw?.status === "offline" || raw?.isOnline === false ? "offline" : prev?.status ?? "offline";
791
+ return {
792
+ id,
793
+ type: rawType === "bot" ? "bot" : rawType === "human" ? "human" : prev?.type ?? "human",
794
+ name: String(raw?.name ?? raw?.displayName ?? raw?.email ?? prev?.name ?? id.slice(0, 8)),
795
+ role: raw?.role === "owner" ? "owner" : "member",
796
+ status
797
+ };
798
+ }
799
+ function normalizeMembers(raw, prev) {
800
+ if (!Array.isArray(raw)) return [];
801
+ const byId = new Map((prev ?? []).map((m) => [m.id, m]));
802
+ return raw.map(
803
+ (r) => normalizeMember(r, byId.get(String(r?.id ?? r?.memberId ?? r?.userId ?? "")))
804
+ );
805
+ }
806
+ function channelSigil(kind) {
807
+ return kind === "dm" ? "@" : "#";
808
+ }
809
+ var RelayError = class extends Error {
810
+ constructor(status, message) {
811
+ super(message);
812
+ this.status = status;
813
+ this.name = "RelayError";
814
+ }
815
+ status;
816
+ };
817
+ var AuthError = class extends RelayError {
818
+ constructor(message = "authentication failed \u2014 run `arp-tui login`") {
819
+ super(401, message);
820
+ this.name = "AuthError";
821
+ }
822
+ };
823
+
824
+ // src/api/rest.ts
825
+ var REQUEST_TIMEOUT_MS = 15e3;
826
+ var RelayRest = class {
827
+ constructor(cfg) {
828
+ this.cfg = cfg;
829
+ }
830
+ cfg;
831
+ async req(method, path3, body) {
832
+ const token = await this.cfg.getToken();
833
+ if (!token) throw new AuthError("no token \u2014 paste a fresh one");
834
+ let res;
835
+ try {
836
+ res = await fetch(`${this.cfg.relayUrl}${path3}`, {
837
+ method,
838
+ headers: {
839
+ Authorization: `Bearer ${token}`,
840
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
841
+ },
842
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
843
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
844
+ });
845
+ } catch (err) {
846
+ throw new RelayError(0, `network error: ${err instanceof Error ? err.message : String(err)}`);
847
+ }
848
+ let data = null;
849
+ let text = "";
850
+ try {
851
+ text = await res.text();
852
+ data = text ? JSON.parse(text) : null;
853
+ } catch {
854
+ }
855
+ if (res.status === 401) {
856
+ throw new AuthError("token expired or invalid \u2014 paste a fresh one");
857
+ }
858
+ if (!res.ok) {
859
+ const detail = data?.error ?? data?.message ?? text.trim().slice(0, 300);
860
+ throw new RelayError(res.status, detail || `HTTP ${res.status} ${res.statusText}`);
861
+ }
862
+ return data;
863
+ }
864
+ /** GET /channels */
865
+ async listChannels() {
866
+ const data = await this.req("GET", "/channels");
867
+ return (data.channels ?? []).map((c) => ({ ...c, members: normalizeMembers(c.members) }));
868
+ }
869
+ /** GET /channels/{id} */
870
+ async getChannel(channelId) {
871
+ const data = await this.req("GET", `/channels/${encodeURIComponent(channelId)}`);
872
+ const ch = data.channel ?? data;
873
+ return { ...ch, members: normalizeMembers(ch?.members) };
874
+ }
875
+ /** GET /channels/{id}/messages?limit&afterSeq — afterSeq for reconnect gap-fill */
876
+ async getMessages(channelId, opts) {
877
+ const qs = new URLSearchParams();
878
+ if (opts?.limit !== void 0) qs.set("limit", String(opts.limit));
879
+ if (opts?.afterSeq !== void 0) qs.set("afterSeq", String(opts.afterSeq));
880
+ const suffix = qs.size > 0 ? `?${qs.toString()}` : "";
881
+ return this.req("GET", `/channels/${encodeURIComponent(channelId)}/messages${suffix}`);
882
+ }
883
+ /** POST /channels/{id}/messages — posts as the authenticated HUMAN (server derives messageType) */
884
+ async postMessage(channelId, body) {
885
+ const data = await this.req(
886
+ "POST",
887
+ `/channels/${encodeURIComponent(channelId)}/messages`,
888
+ body
889
+ );
890
+ return data.message;
891
+ }
892
+ /** POST /presence (human-only) */
893
+ async setPresence(status) {
894
+ await this.req("POST", "/presence", { status });
895
+ }
896
+ };
897
+
898
+ // src/api/ws.ts
899
+ import { appendFileSync } from "fs";
900
+ import WebSocket from "ws";
901
+ var DEBUG_FRAMES = process.env["ARP_TUI_DEBUG"] === "1";
902
+ function debugLogFrame(raw) {
903
+ if (!DEBUG_FRAMES) return;
904
+ try {
905
+ appendFileSync("/tmp/arp-tui-frames.jsonl", raw.replace(/\n/g, " ") + "\n");
906
+ } catch {
907
+ }
908
+ }
909
+ var CONNECT_TIMEOUT_MS = 2e4;
910
+ var PING_INTERVAL_MS = 25e3;
911
+ var WATCHDOG_MS = 4e4;
912
+ var STABLE_RESET_MS = 45e3;
913
+ var BACKOFF_BASE_MS = 1e3;
914
+ var BACKOFF_CAP_MS = 3e4;
915
+ var RelayWs = class {
916
+ constructor(cfg) {
917
+ this.cfg = cfg;
918
+ }
919
+ cfg;
920
+ ws = null;
921
+ stateVal = "disconnected";
922
+ attempts = 0;
923
+ stopped = true;
924
+ everConnected = false;
925
+ subs = /* @__PURE__ */ new Set();
926
+ frameHandlers = [];
927
+ stateHandlers = [];
928
+ authErrorHandlers = [];
929
+ openHandlers = [];
930
+ pingTimer = null;
931
+ watchdogTimer = null;
932
+ stableTimer = null;
933
+ reconnectTimer = null;
934
+ /** epoch ms of the next scheduled reconnect attempt (for status-bar countdown) */
935
+ reconnectAt = null;
936
+ get state() {
937
+ return this.stateVal;
938
+ }
939
+ get attempt() {
940
+ return this.attempts;
941
+ }
942
+ connect() {
943
+ if (!this.stopped) return;
944
+ this.stopped = false;
945
+ void this.dial();
946
+ }
947
+ subscribe(channelId) {
948
+ this.subs.add(channelId);
949
+ this.send({ type: "subscribe", channelId });
950
+ }
951
+ unsubscribe(channelId) {
952
+ this.subs.delete(channelId);
953
+ this.send({ type: "unsubscribe", channelId });
954
+ }
955
+ onFrame(handler) {
956
+ this.frameHandlers.push(handler);
957
+ }
958
+ onStateChange(handler) {
959
+ this.stateHandlers.push(handler);
960
+ }
961
+ /** 401 handshake / 4001-class close / token-supplier failure. No retry follows. */
962
+ onAuthError(handler) {
963
+ this.authErrorHandlers.push(handler);
964
+ }
965
+ /** fires on every successful open; isReconnect=true means gap-fill via afterSeq */
966
+ onOpen(handler) {
967
+ this.openHandlers.push(handler);
968
+ }
969
+ /** manual retry ("r" key) — skips any pending backoff wait */
970
+ reconnectNow() {
971
+ if (this.reconnectTimer) {
972
+ clearTimeout(this.reconnectTimer);
973
+ this.reconnectTimer = null;
974
+ }
975
+ this.reconnectAt = null;
976
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
977
+ this.stopped = false;
978
+ this.teardownSocket();
979
+ void this.dial();
980
+ }
981
+ close() {
982
+ this.stopped = true;
983
+ this.clearTimers();
984
+ if (this.reconnectTimer) {
985
+ clearTimeout(this.reconnectTimer);
986
+ this.reconnectTimer = null;
987
+ }
988
+ this.reconnectAt = null;
989
+ this.teardownSocket();
990
+ this.setState("disconnected");
991
+ }
992
+ // -------------------------------------------------------------------------
993
+ setState(s) {
994
+ if (this.stateVal === s) return;
995
+ this.stateVal = s;
996
+ for (const h of this.stateHandlers) h(s);
997
+ }
998
+ send(frame) {
999
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1000
+ this.ws.send(JSON.stringify(frame));
1001
+ }
1002
+ }
1003
+ emitAuthError(message) {
1004
+ this.stopped = true;
1005
+ this.clearTimers();
1006
+ this.teardownSocket();
1007
+ this.setState("disconnected");
1008
+ for (const h of this.authErrorHandlers) h(message);
1009
+ }
1010
+ teardownSocket() {
1011
+ const ws = this.ws;
1012
+ this.ws = null;
1013
+ if (ws) {
1014
+ ws.removeAllListeners();
1015
+ try {
1016
+ ws.terminate();
1017
+ } catch {
1018
+ }
1019
+ }
1020
+ }
1021
+ clearTimers() {
1022
+ for (const t of [this.pingTimer, this.watchdogTimer, this.stableTimer]) {
1023
+ if (t) clearTimeout(t);
1024
+ }
1025
+ this.pingTimer = null;
1026
+ this.watchdogTimer = null;
1027
+ this.stableTimer = null;
1028
+ }
1029
+ feedWatchdog() {
1030
+ if (this.watchdogTimer) clearTimeout(this.watchdogTimer);
1031
+ this.watchdogTimer = setTimeout(() => {
1032
+ this.ws?.terminate();
1033
+ }, WATCHDOG_MS);
1034
+ }
1035
+ async dial() {
1036
+ if (this.stopped) return;
1037
+ this.setState(this.everConnected ? "reconnecting" : "connecting");
1038
+ let token;
1039
+ try {
1040
+ token = await this.cfg.getToken();
1041
+ if (!token) throw new Error("no token available");
1042
+ } catch (err) {
1043
+ this.emitAuthError(
1044
+ `cannot get a token for the socket: ${err instanceof Error ? err.message : String(err)}`
1045
+ );
1046
+ return;
1047
+ }
1048
+ const wsUrl = this.cfg.relayUrl.replace(/^http/, "ws") + "/ws/client";
1049
+ const ws = new WebSocket(wsUrl, {
1050
+ headers: { Authorization: `Bearer ${token}` },
1051
+ handshakeTimeout: CONNECT_TIMEOUT_MS
1052
+ });
1053
+ this.ws = ws;
1054
+ ws.on("unexpected-response", (_req, res) => {
1055
+ if (ws !== this.ws) return;
1056
+ if (res.statusCode === 401 || res.statusCode === 403) {
1057
+ this.emitAuthError("websocket handshake rejected (401) \u2014 token expired, paste a fresh one");
1058
+ } else {
1059
+ this.teardownSocket();
1060
+ this.scheduleReconnect();
1061
+ }
1062
+ });
1063
+ ws.on("open", () => {
1064
+ if (ws !== this.ws) return;
1065
+ const isReconnect = this.everConnected;
1066
+ this.everConnected = true;
1067
+ this.reconnectAt = null;
1068
+ this.setState("connected");
1069
+ this.feedWatchdog();
1070
+ this.pingTimer = setInterval(() => ws.ping(), PING_INTERVAL_MS);
1071
+ this.stableTimer = setTimeout(() => {
1072
+ this.attempts = 0;
1073
+ }, STABLE_RESET_MS);
1074
+ for (const channelId of this.subs) this.send({ type: "subscribe", channelId });
1075
+ for (const h of this.openHandlers) h(isReconnect);
1076
+ });
1077
+ ws.on("message", (data) => {
1078
+ if (ws !== this.ws) return;
1079
+ this.feedWatchdog();
1080
+ const raw = data.toString();
1081
+ debugLogFrame(raw);
1082
+ let frame;
1083
+ try {
1084
+ frame = JSON.parse(raw);
1085
+ } catch {
1086
+ return;
1087
+ }
1088
+ if (frame.type === "heartbeat") {
1089
+ this.send({ type: "heartbeat_ack", time: Date.now() });
1090
+ }
1091
+ for (const h of this.frameHandlers) h(frame);
1092
+ });
1093
+ ws.on("pong", () => {
1094
+ if (ws !== this.ws) return;
1095
+ this.feedWatchdog();
1096
+ });
1097
+ ws.on("error", () => {
1098
+ });
1099
+ ws.on("close", (code, reasonBuf) => {
1100
+ if (ws !== this.ws) return;
1101
+ this.ws = null;
1102
+ this.clearTimers();
1103
+ if (this.stopped) return;
1104
+ const reason = reasonBuf.toString();
1105
+ if (code === 4401 || code === 4001 || code === 4003 || code === 1008 || /unauthori[sz]ed/i.test(reason)) {
1106
+ this.emitAuthError(`socket closed by relay (${code} ${reason || "unauthorized"}) \u2014 token expired, paste a fresh one`);
1107
+ return;
1108
+ }
1109
+ this.scheduleReconnect();
1110
+ });
1111
+ }
1112
+ scheduleReconnect() {
1113
+ if (this.stopped || this.reconnectTimer) return;
1114
+ const base2 = Math.min(BACKOFF_BASE_MS * 2 ** this.attempts, BACKOFF_CAP_MS);
1115
+ const delay = Math.round(base2 * (1 + Math.random() * 0.3));
1116
+ this.attempts += 1;
1117
+ this.reconnectAt = Date.now() + delay;
1118
+ this.setState("reconnecting");
1119
+ this.reconnectTimer = setTimeout(() => {
1120
+ this.reconnectTimer = null;
1121
+ void this.dial();
1122
+ }, delay);
1123
+ }
1124
+ };
1125
+
1126
+ // src/state/store.ts
1127
+ function filterChannels(channels, filter) {
1128
+ const f = filter.trim().toLowerCase();
1129
+ if (!f) return channels;
1130
+ return channels.filter((c) => c.name.toLowerCase().includes(f));
1131
+ }
1132
+ var IDLE_AWAY_MS = 10 * 6e4;
1133
+ var IDLE_CHECK_MS = 3e4;
1134
+ var TICK_MS = 1e4;
1135
+ var ACTIVITY_AUTOCLEAR_MS = 6e4;
1136
+ var RENDER_THROTTLE_MS = 250;
1137
+ function createStore(opts) {
1138
+ const instance2 = opts.instance;
1139
+ const relayUrl = instance2.relayUrl;
1140
+ let creds = opts.credentials;
1141
+ let config = loadConfig();
1142
+ const refreshable = (c) => !!c?.refreshToken && boundToFapi(c.clerkFapiUrl, instance2.clerkFapiUrl);
1143
+ let initialized = false;
1144
+ let dividerCounter = 0;
1145
+ const seen = /* @__PURE__ */ new Set();
1146
+ const listeners = /* @__PURE__ */ new Set();
1147
+ let state = {
1148
+ channels: [],
1149
+ activeChannelId: null,
1150
+ messages: [],
1151
+ members: [],
1152
+ presence: {},
1153
+ activities: [],
1154
+ connection: "disconnected",
1155
+ reconnectAt: null,
1156
+ reconnectAttempt: 0,
1157
+ mode: "normal",
1158
+ draft: "",
1159
+ switcherFilter: "",
1160
+ lastSeq: null,
1161
+ error: null,
1162
+ tokenType: creds?.tokenType ?? null,
1163
+ tokenObtainedAt: creds?.obtainedAt ?? null,
1164
+ tokenRefreshable: refreshable(creds),
1165
+ // for an oauth slot the clerk supplier is a FALLBACK, not the live path —
1166
+ // "on" only once it actually mints (getToken flips it)
1167
+ clerkRefresh: creds?.clerkClientToken && creds.tokenType !== "oauth" ? "on" : "off",
1168
+ tokenPrompt: "",
1169
+ tokenPromptReason: null
1170
+ };
1171
+ let lastNotifyAt = 0;
1172
+ let notifyTimer = null;
1173
+ function notifyNow() {
1174
+ if (notifyTimer) {
1175
+ clearTimeout(notifyTimer);
1176
+ notifyTimer = null;
1177
+ }
1178
+ lastNotifyAt = Date.now();
1179
+ for (const l of listeners) l();
1180
+ }
1181
+ function notifyThrottled() {
1182
+ if (notifyTimer) return;
1183
+ const elapsed = Date.now() - lastNotifyAt;
1184
+ if (elapsed >= RENDER_THROTTLE_MS) {
1185
+ notifyNow();
1186
+ return;
1187
+ }
1188
+ notifyTimer = setTimeout(() => {
1189
+ notifyTimer = null;
1190
+ notifyNow();
1191
+ }, RENDER_THROTTLE_MS - elapsed);
1192
+ }
1193
+ function set(partial) {
1194
+ state = { ...state, ...partial };
1195
+ notifyNow();
1196
+ }
1197
+ function setThrottled(partial) {
1198
+ state = { ...state, ...partial };
1199
+ notifyThrottled();
1200
+ }
1201
+ let saveTimer = null;
1202
+ function saveConfigSoon() {
1203
+ if (saveTimer) return;
1204
+ saveTimer = setTimeout(() => {
1205
+ saveTimer = null;
1206
+ try {
1207
+ saveConfig(config);
1208
+ } catch {
1209
+ }
1210
+ }, 500);
1211
+ }
1212
+ let clerkSupplier = creds?.clerkClientToken ? createClerkTokenSupplier(creds.clerkClientToken, {
1213
+ fapiUrl: instance2.clerkFapiUrl,
1214
+ template: instance2.clerkJwtTemplate
1215
+ }) : null;
1216
+ let oauthRefresh = null;
1217
+ let unpersistedRefreshToken = null;
1218
+ async function refreshOauthNow() {
1219
+ const before = creds;
1220
+ if (!before?.refreshToken || !boundToFapi(before.clerkFapiUrl, instance2.clerkFapiUrl)) return;
1221
+ const disk = loadStoredCredentials(relayUrl);
1222
+ const diskRt = disk?.refreshToken && boundToFapi(disk.clerkFapiUrl, instance2.clerkFapiUrl) ? disk.refreshToken : null;
1223
+ const consumed = chooseRefreshToken({
1224
+ memoryRt: before.refreshToken,
1225
+ diskRt,
1226
+ memorySub: before.sub,
1227
+ diskSub: disk?.sub,
1228
+ // fix #2: a prior rotation we couldn't persist lives only in memory and is
1229
+ // known-newer than the stale disk token — consume it, never replay disk.
1230
+ knownNewerInMemory: unpersistedRefreshToken === before.refreshToken
1231
+ });
1232
+ if (!consumed) {
1233
+ creds = { ...before, refreshToken: void 0 };
1234
+ unpersistedRefreshToken = null;
1235
+ setThrottled({ tokenRefreshable: false });
1236
+ return;
1237
+ }
1238
+ const tokens = await refreshGrant({
1239
+ endpoints: oauthEndpoints(instance2.clerkFapiUrl),
1240
+ clientId: requireOAuthClientId(instance2),
1241
+ refreshToken: consumed
1242
+ });
1243
+ const rotated = tokens.refreshToken ?? consumed;
1244
+ const fields = {
1245
+ token: tokens.accessToken,
1246
+ tokenType: "oauth",
1247
+ // a slot holding a refresh token is ALWAYS an oauth slot
1248
+ obtainedAt: (/* @__PURE__ */ new Date()).toISOString(),
1249
+ expiresAt: tokens.expiresAt ?? jwtExpiry(tokens.accessToken),
1250
+ refreshToken: rotated
1251
+ };
1252
+ let stored = null;
1253
+ let wroteDisk = true;
1254
+ try {
1255
+ stored = updateCredentials(relayUrl, (onDisk) => mergeRotation(onDisk, fields, consumed));
1256
+ } catch (err) {
1257
+ wroteDisk = false;
1258
+ const msg = err instanceof Error ? err.message : String(err);
1259
+ if (creds && !(creds !== before && !creds.refreshToken)) {
1260
+ unpersistedRefreshToken = rotated;
1261
+ creds = { ...creds, ...fields };
1262
+ setThrottled({
1263
+ tokenType: creds.tokenType,
1264
+ tokenObtainedAt: creds.obtainedAt,
1265
+ tokenRefreshable: refreshable(creds),
1266
+ error: `token refreshed but couldn't be written to disk (${msg}) \u2014 still active this session; will retry saving`
1267
+ });
1268
+ }
1269
+ return;
1270
+ }
1271
+ if (wroteDisk && stored === null) {
1272
+ if (creds) creds = { ...creds, refreshToken: void 0 };
1273
+ unpersistedRefreshToken = null;
1274
+ setThrottled({ tokenRefreshable: false });
1275
+ return;
1276
+ }
1277
+ if (!creds || creds !== before && !creds.refreshToken) {
1278
+ return;
1279
+ }
1280
+ unpersistedRefreshToken = null;
1281
+ creds = { ...creds, ...fields };
1282
+ setThrottled({
1283
+ tokenType: creds.tokenType,
1284
+ tokenObtainedAt: creds.obtainedAt,
1285
+ tokenRefreshable: refreshable(creds)
1286
+ });
1287
+ }
1288
+ async function getToken() {
1289
+ if (refreshable(creds) && needsRefresh(creds?.expiresAt)) {
1290
+ oauthRefresh ??= refreshOauthNow().catch(() => {
1291
+ }).finally(() => {
1292
+ oauthRefresh = null;
1293
+ });
1294
+ const stillServable = creds?.token && !isExpired(creds.expiresAt);
1295
+ if (!stillServable) {
1296
+ try {
1297
+ await oauthRefresh;
1298
+ } catch {
1299
+ }
1300
+ }
1301
+ }
1302
+ if (creds?.tokenType === "oauth" && creds.token && !isExpired(creds.expiresAt)) {
1303
+ return creds.token;
1304
+ }
1305
+ if (clerkSupplier) {
1306
+ try {
1307
+ const jwt = await clerkSupplier();
1308
+ if (state.clerkRefresh !== "on") set({ clerkRefresh: "on" });
1309
+ return jwt;
1310
+ } catch {
1311
+ clerkSupplier = null;
1312
+ set({ clerkRefresh: "failed" });
1313
+ }
1314
+ }
1315
+ if (!creds?.token) throw new AuthError("no token \u2014 run `arp-tui login` (or paste a fresh one)");
1316
+ return creds.token;
1317
+ }
1318
+ const rest = new RelayRest({ relayUrl, getToken });
1319
+ const ws = new RelayWs({ relayUrl, getToken });
1320
+ function pushDivider(label) {
1321
+ const id = `divider-${++dividerCounter}`;
1322
+ seen.add(id);
1323
+ const row = {
1324
+ id,
1325
+ content: label,
1326
+ messageType: "system",
1327
+ agentName: "",
1328
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1329
+ seq: -1,
1330
+ tokensUsed: 0,
1331
+ verified: false
1332
+ };
1333
+ setThrottled({ messages: [...state.messages, row] });
1334
+ }
1335
+ function appendMessages(channelId, msgs) {
1336
+ const fresh = msgs.filter((m) => m && !seen.has(m.id)).sort((a, b) => a.seq - b.seq);
1337
+ if (fresh.length === 0) return 0;
1338
+ for (const m of fresh) seen.add(m.id);
1339
+ const maxSeq = Math.max(...fresh.map((m) => m.seq), config.lastSeq?.[channelId] ?? 0);
1340
+ config = { ...config, lastSeq: { ...config.lastSeq ?? {}, [channelId]: maxSeq } };
1341
+ saveConfigSoon();
1342
+ setThrottled({
1343
+ messages: [...state.messages, ...fresh],
1344
+ lastSeq: channelId === state.activeChannelId ? maxSeq : state.lastSeq
1345
+ });
1346
+ return fresh.length;
1347
+ }
1348
+ function seedPresence(members) {
1349
+ const presence = {};
1350
+ for (const m of members) presence[m.id] = m.status === "online" ? "active" : m.status;
1351
+ return presence;
1352
+ }
1353
+ let activitySweep = null;
1354
+ function scheduleActivitySweep() {
1355
+ if (activitySweep) {
1356
+ clearTimeout(activitySweep);
1357
+ activitySweep = null;
1358
+ }
1359
+ if (state.activities.length === 0) return;
1360
+ const next = Math.min(...state.activities.map((a) => a.expiresAt));
1361
+ activitySweep = setTimeout(
1362
+ () => {
1363
+ activitySweep = null;
1364
+ const now = Date.now();
1365
+ const kept = state.activities.filter((a) => a.expiresAt > now);
1366
+ if (kept.length !== state.activities.length) setThrottled({ activities: kept });
1367
+ scheduleActivitySweep();
1368
+ },
1369
+ Math.max(next - Date.now(), 250)
1370
+ );
1371
+ }
1372
+ function activityExpiry(expiresAt) {
1373
+ const now = Date.now();
1374
+ if (expiresAt) {
1375
+ const t = Date.parse(expiresAt);
1376
+ if (Number.isFinite(t)) {
1377
+ if (t <= now) return null;
1378
+ return Math.min(t, now + ACTIVITY_AUTOCLEAR_MS);
1379
+ }
1380
+ }
1381
+ return now + ACTIVITY_AUTOCLEAR_MS;
1382
+ }
1383
+ function applyActivity(actorId, activity, botHint, expiresAt) {
1384
+ const expiry = activityExpiry(expiresAt);
1385
+ if (expiry === null) {
1386
+ removeActivity((a) => a.actorId === actorId);
1387
+ return;
1388
+ }
1389
+ const member = state.members.find((m) => m.id === actorId);
1390
+ const entry = {
1391
+ actorId,
1392
+ agentName: member?.name ?? actorId,
1393
+ activity,
1394
+ bot: member ? member.type === "bot" : botHint,
1395
+ expiresAt: expiry
1396
+ };
1397
+ const idx = state.activities.findIndex((a) => a.actorId === actorId);
1398
+ if (idx >= 0) {
1399
+ const prev = state.activities[idx];
1400
+ const visiblySame = prev.agentName === entry.agentName && prev.activity === entry.activity && prev.bot === entry.bot;
1401
+ const activities = state.activities.map((a, i) => i === idx ? entry : a);
1402
+ if (visiblySame) {
1403
+ state = { ...state, activities };
1404
+ } else {
1405
+ setThrottled({ activities });
1406
+ }
1407
+ } else {
1408
+ setThrottled({ activities: [...state.activities, entry] });
1409
+ }
1410
+ scheduleActivitySweep();
1411
+ }
1412
+ function removeActivity(pred) {
1413
+ const kept = state.activities.filter((a) => !pred(a));
1414
+ if (kept.length !== state.activities.length) {
1415
+ setThrottled({ activities: kept });
1416
+ scheduleActivitySweep();
1417
+ }
1418
+ }
1419
+ let countdownTimer = null;
1420
+ function syncCountdown(s) {
1421
+ if (s === "reconnecting" && !countdownTimer) {
1422
+ countdownTimer = setInterval(
1423
+ () => setThrottled({ reconnectAt: ws.reconnectAt, reconnectAttempt: ws.attempt }),
1424
+ 1e3
1425
+ );
1426
+ } else if (s !== "reconnecting" && countdownTimer) {
1427
+ clearInterval(countdownTimer);
1428
+ countdownTimer = null;
1429
+ }
1430
+ }
1431
+ ws.onStateChange((s) => {
1432
+ syncCountdown(s);
1433
+ set({ connection: s, reconnectAt: ws.reconnectAt, reconnectAttempt: ws.attempt });
1434
+ });
1435
+ ws.onAuthError((message) => enterTokenPrompt(message));
1436
+ let pendingGapCursor = null;
1437
+ ws.onOpen((isReconnect) => {
1438
+ if (isReconnect) {
1439
+ pendingGapCursor = config.lastSeq?.[state.activeChannelId ?? ""] ?? null;
1440
+ void gapFill();
1441
+ }
1442
+ });
1443
+ ws.onFrame((frame) => {
1444
+ switch (frame.type) {
1445
+ case "connected": {
1446
+ if (frame.channelId !== state.activeChannelId) return;
1447
+ const members = normalizeMembers(frame.members, state.members);
1448
+ if (members.length > 0) {
1449
+ setThrottled({ members, presence: { ...seedPresence(members), ...state.presence } });
1450
+ }
1451
+ if (pendingGapCursor !== null) {
1452
+ const cursor = pendingGapCursor;
1453
+ pendingGapCursor = null;
1454
+ const missed = (frame.messages ?? []).filter(
1455
+ (m) => m && !seen.has(m.id) && m.seq > cursor
1456
+ ).length;
1457
+ if (missed > 0) pushDivider(`reconnected \u2014 ${missed} missed`);
1458
+ }
1459
+ appendMessages(frame.channelId, frame.messages ?? []);
1460
+ return;
1461
+ }
1462
+ case "new_message": {
1463
+ if (frame.channelId !== state.activeChannelId) return;
1464
+ appendMessages(frame.channelId, [frame.message]);
1465
+ const senderId = frame.message.agentId;
1466
+ removeActivity(
1467
+ (a) => senderId != null && a.actorId === senderId || a.agentName === frame.message.agentName
1468
+ );
1469
+ return;
1470
+ }
1471
+ case "presence_changed": {
1472
+ if (frame.channelId !== state.activeChannelId) return;
1473
+ setThrottled({ presence: { ...state.presence, [frame.userId]: frame.status } });
1474
+ return;
1475
+ }
1476
+ case "activity_start": {
1477
+ if (frame.channelId !== state.activeChannelId) return;
1478
+ applyActivity(frame.agentId, frame.activity, true);
1479
+ return;
1480
+ }
1481
+ case "activity_stop": {
1482
+ if (frame.channelId !== state.activeChannelId) return;
1483
+ removeActivity((a) => a.actorId === frame.agentId);
1484
+ return;
1485
+ }
1486
+ // what the live relay actually sends (drift, 2026-07-09) — replaces start/stop
1487
+ case "activity_changed": {
1488
+ if (frame.channelId !== state.activeChannelId) return;
1489
+ if (frame.activity === "idle") {
1490
+ removeActivity((a) => a.actorId === frame.actorId);
1491
+ return;
1492
+ }
1493
+ applyActivity(frame.actorId, frame.activity, frame.actorType === "bot", frame.expiresAt);
1494
+ return;
1495
+ }
1496
+ case "member_added":
1497
+ case "member_removed": {
1498
+ if (frame.channelId !== state.activeChannelId) return;
1499
+ void refreshRoster();
1500
+ return;
1501
+ }
1502
+ case "subscribe_error": {
1503
+ set({ error: `subscribe failed for ${frame.channelId}: ${frame.error}` });
1504
+ return;
1505
+ }
1506
+ case "channel_archived": {
1507
+ pushDivider("channel archived");
1508
+ return;
1509
+ }
1510
+ default:
1511
+ return;
1512
+ }
1513
+ });
1514
+ async function refreshRoster() {
1515
+ const cid = state.activeChannelId;
1516
+ if (!cid) return;
1517
+ try {
1518
+ const ch = await rest.getChannel(cid);
1519
+ if (state.activeChannelId !== cid || !ch?.members) return;
1520
+ setThrottled({
1521
+ members: ch.members,
1522
+ presence: { ...seedPresence(ch.members), ...state.presence }
1523
+ });
1524
+ } catch {
1525
+ }
1526
+ }
1527
+ async function gapFill() {
1528
+ const cid = state.activeChannelId;
1529
+ const cursor = config.lastSeq?.[cid ?? ""];
1530
+ if (!cid || cursor === void 0) return;
1531
+ try {
1532
+ const page = await rest.getMessages(cid, { afterSeq: cursor, limit: 200 });
1533
+ const missed = (page.messages ?? []).filter((m) => !seen.has(m.id));
1534
+ if (missed.length > 0) {
1535
+ pushDivider(`reconnected \u2014 ${missed.length} missed`);
1536
+ appendMessages(cid, missed);
1537
+ }
1538
+ } catch (err) {
1539
+ if (err instanceof AuthError) enterTokenPrompt(err.message);
1540
+ }
1541
+ }
1542
+ let reportedPresence = null;
1543
+ let lastActivityAt = Date.now();
1544
+ let idleTimer = null;
1545
+ let tickTimer = null;
1546
+ function reportPresence(status) {
1547
+ if (reportedPresence === status) return;
1548
+ reportedPresence = status;
1549
+ rest.setPresence(status).catch(() => {
1550
+ reportedPresence = null;
1551
+ });
1552
+ }
1553
+ function noteUserActivity() {
1554
+ lastActivityAt = Date.now();
1555
+ if (initialized && reportedPresence === "away") reportPresence("active");
1556
+ }
1557
+ function enterTokenPrompt(reason) {
1558
+ set({ mode: "token", tokenPrompt: "", tokenPromptReason: reason });
1559
+ }
1560
+ async function submitToken() {
1561
+ const t = state.tokenPrompt.trim().replace(/^["']+|["']+$/g, "");
1562
+ if (!t) return;
1563
+ set({ tokenPromptReason: "validating\u2026" });
1564
+ let next;
1565
+ try {
1566
+ next = makeCredentials(relayUrl, t);
1567
+ } catch (err) {
1568
+ set({ tokenPromptReason: err instanceof Error ? err.message : String(err) });
1569
+ return;
1570
+ }
1571
+ const probe = new RelayRest({ relayUrl, getToken: async () => t });
1572
+ try {
1573
+ await probe.listChannels();
1574
+ } catch (err) {
1575
+ const msg = err instanceof Error ? err.message : String(err);
1576
+ set({ tokenPromptReason: `that token didn't work (${msg}) \u2014 paste a fresh one` });
1577
+ return;
1578
+ }
1579
+ let stored;
1580
+ try {
1581
+ stored = updateCredentials(relayUrl, (onDisk) => mergePastedToken(onDisk, next));
1582
+ } catch {
1583
+ stored = next;
1584
+ }
1585
+ const boundDisk = stored && boundToFapi(stored.clerkFapiUrl, instance2.clerkFapiUrl) ? stored : null;
1586
+ creds = boundDisk ? { ...boundDisk, clerkClientToken: creds?.clerkClientToken ?? boundDisk.clerkClientToken } : {
1587
+ ...next,
1588
+ clerkClientToken: creds?.clerkClientToken,
1589
+ clerkFapiUrl: creds?.clerkClientToken ? instance2.clerkFapiUrl : void 0
1590
+ };
1591
+ set({
1592
+ mode: "normal",
1593
+ tokenPrompt: "",
1594
+ tokenPromptReason: null,
1595
+ tokenType: creds.tokenType,
1596
+ tokenObtainedAt: creds.obtainedAt,
1597
+ tokenRefreshable: refreshable(creds),
1598
+ error: null
1599
+ });
1600
+ if (!initialized) void init();
1601
+ else if (state.connection !== "connected") ws.reconnectNow();
1602
+ }
1603
+ async function init() {
1604
+ if (!creds || !creds.token && !clerkSupplier) {
1605
+ enterTokenPrompt("no token found \u2014 paste a fresh one to connect");
1606
+ return;
1607
+ }
1608
+ set({
1609
+ tokenType: creds.tokenType,
1610
+ tokenObtainedAt: creds.obtainedAt,
1611
+ tokenRefreshable: refreshable(creds),
1612
+ error: null
1613
+ });
1614
+ let channels;
1615
+ try {
1616
+ channels = await rest.listChannels();
1617
+ } catch (err) {
1618
+ if (err instanceof AuthError) {
1619
+ enterTokenPrompt("token expired or invalid \u2014 paste a fresh one");
1620
+ return;
1621
+ }
1622
+ set({ error: `cannot reach relay: ${err instanceof Error ? err.message : String(err)}` });
1623
+ return;
1624
+ }
1625
+ initialized = true;
1626
+ set({ channels });
1627
+ ws.connect();
1628
+ reportPresence("active");
1629
+ if (!idleTimer) {
1630
+ idleTimer = setInterval(() => {
1631
+ if (Date.now() - lastActivityAt > IDLE_AWAY_MS && reportedPresence === "active") {
1632
+ reportPresence("away");
1633
+ }
1634
+ }, IDLE_CHECK_MS);
1635
+ }
1636
+ if (!tickTimer) {
1637
+ tickTimer = setInterval(() => setThrottled({}), TICK_MS);
1638
+ }
1639
+ const target = channels.find((c) => c.id === config.lastChannelId)?.id ?? channels[0]?.id ?? null;
1640
+ if (target) openChannel(target);
1641
+ else set({ error: "no channels yet for this account \u2014 create one on the web app, or ask an admin to add you" });
1642
+ }
1643
+ function openChannel(channelId) {
1644
+ if (channelId === state.activeChannelId) {
1645
+ set({ mode: "normal" });
1646
+ return;
1647
+ }
1648
+ if (state.activeChannelId) ws.unsubscribe(state.activeChannelId);
1649
+ const ch = state.channels.find((c) => c.id === channelId);
1650
+ const members = ch?.members ?? [];
1651
+ pushDivider(`${channelSigil(ch?.kind)}${ch?.name ?? channelId}`);
1652
+ set({
1653
+ activeChannelId: channelId,
1654
+ members,
1655
+ presence: seedPresence(members),
1656
+ activities: [],
1657
+ mode: "normal",
1658
+ switcherFilter: "",
1659
+ lastSeq: config.lastSeq?.[channelId] ?? null,
1660
+ error: null
1661
+ });
1662
+ ws.subscribe(channelId);
1663
+ config = { ...config, lastChannelId: channelId };
1664
+ saveConfigSoon();
1665
+ }
1666
+ async function sendDraft() {
1667
+ const content = state.draft.trim();
1668
+ const cid = state.activeChannelId;
1669
+ if (!content || !cid) return;
1670
+ set({ error: null });
1671
+ try {
1672
+ const msg = await rest.postMessage(cid, { id: randomUUID(), content });
1673
+ appendMessages(cid, [msg]);
1674
+ set({ draft: "" });
1675
+ } catch (err) {
1676
+ if (err instanceof AuthError) {
1677
+ enterTokenPrompt("token expired \u2014 paste a fresh one (your draft is kept)");
1678
+ } else {
1679
+ const msg = err instanceof Error ? err.message : String(err);
1680
+ set({ error: `send failed: ${msg} \u2014 draft kept, Enter retries` });
1681
+ }
1682
+ }
1683
+ }
1684
+ function quit() {
1685
+ if (saveTimer) {
1686
+ clearTimeout(saveTimer);
1687
+ saveTimer = null;
1688
+ }
1689
+ try {
1690
+ saveConfig(config);
1691
+ } catch {
1692
+ }
1693
+ if (idleTimer) clearInterval(idleTimer);
1694
+ if (tickTimer) clearInterval(tickTimer);
1695
+ if (activitySweep) clearTimeout(activitySweep);
1696
+ if (countdownTimer) clearInterval(countdownTimer);
1697
+ if (notifyTimer) {
1698
+ clearTimeout(notifyTimer);
1699
+ notifyTimer = null;
1700
+ }
1701
+ ws.close();
1702
+ }
1703
+ return {
1704
+ instance: instance2,
1705
+ getState: () => state,
1706
+ subscribe: (listener) => {
1707
+ listeners.add(listener);
1708
+ return () => listeners.delete(listener);
1709
+ },
1710
+ init: () => void init(),
1711
+ openChannel,
1712
+ // entering the switcher always starts with an empty filter
1713
+ setMode: (mode) => set(mode === "switcher" ? { mode, switcherFilter: "" } : { mode }),
1714
+ setDraft: (draft) => set({ draft }),
1715
+ setSwitcherFilter: (filter) => set({ switcherFilter: filter }),
1716
+ setTokenPrompt: (value) => set({ tokenPrompt: value }),
1717
+ submitToken: () => void submitToken(),
1718
+ sendDraft: () => void sendDraft(),
1719
+ reconnectNow: () => ws.reconnectNow(),
1720
+ noteUserActivity,
1721
+ quit
1722
+ };
1723
+ }
1724
+
1725
+ // src/ui/ActivityLine.tsx
1726
+ import { Text } from "ink";
1727
+
1728
+ // src/util/sanitize.ts
1729
+ var ESCAPE_SEQ = (
1730
+ // eslint-disable-next-line no-control-regex
1731
+ /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][\s\S]*?(?:\x07|\x1b\\)|[PX^_][\s\S]*?\x1b\\|[@-Z\\-_])/g
1732
+ );
1733
+ var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
1734
+ function sanitizeForTty(input) {
1735
+ return input.replace(ESCAPE_SEQ, "").replace(CONTROL_CHARS, "");
1736
+ }
1737
+ function sanitizeLabel(input, max = 40) {
1738
+ const clean = sanitizeForTty(input);
1739
+ return clean.length > max ? clean.slice(0, max - 1) + "\u2026" : clean;
1740
+ }
1741
+
1742
+ // src/ui/ActivityLine.tsx
1743
+ import { jsx, jsxs } from "react/jsx-runtime";
1744
+ var MAX_SHOWN = 3;
1745
+ function ActivityLine({ activities }) {
1746
+ if (activities.length === 0) return /* @__PURE__ */ jsx(Text, { children: " " });
1747
+ const shown = activities.slice(0, MAX_SHOWN);
1748
+ const extra = activities.length - shown.length;
1749
+ return /* @__PURE__ */ jsxs(Text, { dimColor: true, italic: true, children: [
1750
+ shown.map(
1751
+ (a) => `${a.bot ? "\u2699" : ""}${sanitizeLabel(a.agentName, 20)} is ${sanitizeLabel(a.activity, 20)}`
1752
+ ).join(" \xB7 "),
1753
+ extra > 0 ? ` \xB7 +${extra}` : "",
1754
+ "\u2026"
1755
+ ] });
1756
+ }
1757
+
1758
+ // src/ui/ChannelSwitcher.tsx
1759
+ import { Box, Text as Text2 } from "ink";
1760
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1761
+ function channelLabel(ch) {
1762
+ return `${channelSigil(ch.kind)}${sanitizeLabel(ch.name)}`;
1763
+ }
1764
+ function ChannelSwitcher({
1765
+ channels,
1766
+ activeChannelId,
1767
+ filter
1768
+ }) {
1769
+ const filtered = filterChannels(channels, filter);
1770
+ const visible = filtered.slice(0, 9);
1771
+ const hidden = filtered.length - visible.length;
1772
+ return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1773
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
1774
+ "channels",
1775
+ " ",
1776
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1777
+ "(",
1778
+ filtered.length,
1779
+ "/",
1780
+ channels.length,
1781
+ ")"
1782
+ ] })
1783
+ ] }),
1784
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1785
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "filter: " }),
1786
+ /* @__PURE__ */ jsx2(Text2, { children: sanitizeLabel(filter, 40) }),
1787
+ /* @__PURE__ */ jsx2(Text2, { inverse: true, children: " " })
1788
+ ] }),
1789
+ visible.map((ch, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
1790
+ /* @__PURE__ */ jsxs2(Text2, { color: "cyan", children: [
1791
+ i + 1,
1792
+ " "
1793
+ ] }),
1794
+ /* @__PURE__ */ jsx2(Text2, { bold: ch.id === activeChannelId, children: channelLabel(ch) }),
1795
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1796
+ " \xB7 ",
1797
+ ch.members.length,
1798
+ " members"
1799
+ ] })
1800
+ ] }, ch.id)),
1801
+ hidden > 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1802
+ "\u2026",
1803
+ hidden,
1804
+ " more \u2014 keep typing to narrow"
1805
+ ] }) : null,
1806
+ filtered.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1807
+ 'no channels match "',
1808
+ sanitizeLabel(filter, 20),
1809
+ '"'
1810
+ ] }) : null,
1811
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: filter.length === 0 ? "type to filter \xB7 1\u20139 quick-select \xB7 Esc cancel" : "keep typing to narrow \xB7 Enter opens top match \xB7 Esc cancel" })
1812
+ ] });
1813
+ }
1814
+
1815
+ // src/ui/Composer.tsx
1816
+ import { Box as Box2, Text as Text3 } from "ink";
1817
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1818
+ function Composer({ draft, focused }) {
1819
+ const width = Math.max((process.stdout.columns ?? 80) - 8, 20);
1820
+ const visible = draft.length > width ? "\u2026" + draft.slice(-(width - 1)) : draft;
1821
+ return /* @__PURE__ */ jsx3(Box2, { borderStyle: "round", borderColor: focused ? "cyan" : "gray", paddingX: 1, width: "100%", children: /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", children: [
1822
+ /* @__PURE__ */ jsx3(Text3, { color: focused ? "cyan" : "gray", children: "> " }),
1823
+ draft.length === 0 && !focused ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "i to compose" }) : /* @__PURE__ */ jsx3(Text3, { children: visible }),
1824
+ focused ? /* @__PURE__ */ jsx3(Text3, { inverse: true, children: " " }) : null
1825
+ ] }) });
1826
+ }
1827
+
1828
+ // src/ui/Header.tsx
1829
+ import { Box as Box3, Text as Text4 } from "ink";
1830
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1831
+ var STATUS_DOT = {
1832
+ online: { glyph: "\u25CF", color: "green" },
1833
+ away: { glyph: "\u25D0", color: "yellow" },
1834
+ offline: { glyph: "\u25CB", color: "gray" }
1835
+ };
1836
+ function effectiveStatus(m, presence) {
1837
+ const live = presence[m.id];
1838
+ if (live !== void 0) return live === "active" ? "online" : live;
1839
+ return m.status;
1840
+ }
1841
+ function Header({
1842
+ channelName,
1843
+ channelKind,
1844
+ members,
1845
+ presence
1846
+ }) {
1847
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
1848
+ /* @__PURE__ */ jsxs4(Box3, { justifyContent: "space-between", children: [
1849
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1850
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", bold: true, children: "arp-tui" }),
1851
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \u25B8 " }),
1852
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
1853
+ channelSigil(channelKind),
1854
+ sanitizeLabel(channelName)
1855
+ ] })
1856
+ ] }),
1857
+ /* @__PURE__ */ jsx4(Text4, { children: members.map((m) => {
1858
+ const dot = STATUS_DOT[effectiveStatus(m, presence)];
1859
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
1860
+ /* @__PURE__ */ jsxs4(Text4, { color: dot.color, children: [
1861
+ dot.glyph,
1862
+ " "
1863
+ ] }),
1864
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: effectiveStatus(m, presence) === "offline", children: [
1865
+ m.type === "bot" ? "\u2699" : "",
1866
+ sanitizeLabel(m.name, 20)
1867
+ ] }),
1868
+ /* @__PURE__ */ jsx4(Text4, { children: " " })
1869
+ ] }, m.id);
1870
+ }) })
1871
+ ] }),
1872
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2500".repeat(Math.min(process.stdout.columns ?? 80, 100)) })
1873
+ ] });
1874
+ }
1875
+
1876
+ // src/ui/MessageList.tsx
1877
+ import { Static } from "ink";
1878
+
1879
+ // src/ui/MessageRow.tsx
1880
+ import { Box as Box4, Text as Text5 } from "ink";
1881
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1882
+ function hhmm(rfc3339) {
1883
+ const d = new Date(rfc3339);
1884
+ return isNaN(d.getTime()) ? "--:--" : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1885
+ }
1886
+ function formatTokens(n) {
1887
+ if (n < 1e3) return String(n);
1888
+ return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1889
+ }
1890
+ function TokenChip({ message }) {
1891
+ if (message.messageType !== "agent" || !message.tokensUsed) return null;
1892
+ const parts = [`${formatTokens(message.tokensUsed)} tok`];
1893
+ if (message.model) parts.push(sanitizeLabel(message.model, 16));
1894
+ return /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1895
+ message.verified ? "\u2713 " : "",
1896
+ parts.join(" \xB7 ")
1897
+ ] });
1898
+ }
1899
+ function MessageRow({ message }) {
1900
+ const isBot = message.messageType === "agent";
1901
+ const isSystem = message.messageType === "system";
1902
+ const name = sanitizeLabel(message.agentName, 20);
1903
+ if (isSystem) {
1904
+ return /* @__PURE__ */ jsx5(Box4, { children: /* @__PURE__ */ jsxs5(Text5, { dimColor: true, italic: true, children: [
1905
+ " ",
1906
+ "\u2500\u2500 ",
1907
+ sanitizeForTty(message.content),
1908
+ " \u2500\u2500"
1909
+ ] }) });
1910
+ }
1911
+ return /* @__PURE__ */ jsxs5(Box4, { justifyContent: "space-between", gap: 2, children: [
1912
+ /* @__PURE__ */ jsx5(Box4, { flexShrink: 1, children: /* @__PURE__ */ jsxs5(Text5, { children: [
1913
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1914
+ hhmm(message.createdAt),
1915
+ " "
1916
+ ] }),
1917
+ /* @__PURE__ */ jsxs5(Text5, { color: isBot ? "magenta" : "cyan", bold: !isBot, children: [
1918
+ isBot ? "\u2699" : "",
1919
+ name.padEnd(12)
1920
+ ] }),
1921
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1922
+ " ",
1923
+ sanitizeForTty(message.content)
1924
+ ] })
1925
+ ] }) }),
1926
+ /* @__PURE__ */ jsx5(Box4, { flexShrink: 0, children: /* @__PURE__ */ jsx5(TokenChip, { message }) })
1927
+ ] });
1928
+ }
1929
+
1930
+ // src/ui/MessageList.tsx
1931
+ import { jsx as jsx6 } from "react/jsx-runtime";
1932
+ function MessageList({ messages }) {
1933
+ return /* @__PURE__ */ jsx6(Static, { items: messages, children: (m) => /* @__PURE__ */ jsx6(MessageRow, { message: m }, m.id) });
1934
+ }
1935
+
1936
+ // src/ui/StatusBar.tsx
1937
+ import { Text as Text6 } from "ink";
1938
+ import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1939
+ var MODE_LABEL = {
1940
+ normal: "NORMAL",
1941
+ insert: "INSERT",
1942
+ switcher: "CHANNELS",
1943
+ token: "TOKEN"
1944
+ };
1945
+ function ageLabel(obtainedAt) {
1946
+ if (!obtainedAt) return "\u2014";
1947
+ const ms = Date.now() - new Date(obtainedAt).getTime();
1948
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
1949
+ if (ms < 9e4) return `${Math.round(ms / 1e3)}s`;
1950
+ if (ms < 90 * 6e4) return `${Math.round(ms / 6e4)}m`;
1951
+ return `${Math.round(ms / 36e5)}h`;
1952
+ }
1953
+ function connectionLabel(connection, reconnectAt, attempt) {
1954
+ if (connection === "reconnecting") {
1955
+ if (reconnectAt !== null) {
1956
+ const secs = Math.max(0, Math.ceil((reconnectAt - Date.now()) / 1e3));
1957
+ return `reconnecting in ${secs}s (attempt ${attempt})`;
1958
+ }
1959
+ return `reconnecting\u2026 (attempt ${attempt})`;
1960
+ }
1961
+ if (connection === "disconnected") return "offline \u2014 r to retry";
1962
+ return connection;
1963
+ }
1964
+ function StatusBar({
1965
+ mode,
1966
+ connection,
1967
+ reconnectAt,
1968
+ reconnectAttempt,
1969
+ lastSeq,
1970
+ tokenType,
1971
+ tokenObtainedAt,
1972
+ tokenRefreshable,
1973
+ clerkRefresh,
1974
+ error
1975
+ }) {
1976
+ const connColor = connection === "connected" ? "green" : connection === "reconnecting" ? "yellow" : "red";
1977
+ let tokenSeg;
1978
+ if (!tokenType) {
1979
+ tokenSeg = { text: "no token", color: "red" };
1980
+ } else if (tokenType === "oauth") {
1981
+ tokenSeg = tokenRefreshable ? { text: "oauth (auto-refresh)", color: "green" } : clerkRefresh === "on" ? { text: "oauth + clerk mint (experimental)", color: "green" } : { text: "oauth (NO refresh token \u2014 will re-prompt at expiry)", color: "yellow" };
1982
+ } else if (clerkRefresh === "on") {
1983
+ tokenSeg = { text: "token auto-refresh (clerk, experimental)", color: "green" };
1984
+ } else {
1985
+ const age = ageLabel(tokenObtainedAt);
1986
+ const ageMs = tokenObtainedAt ? Date.now() - new Date(tokenObtainedAt).getTime() : Infinity;
1987
+ const stale = tokenType === "dev-jwt" && ageMs > 6e4;
1988
+ tokenSeg = {
1989
+ text: `${tokenType} ${age}${stale ? " (REST expired \u2014 posts will re-prompt)" : ""}`,
1990
+ color: stale ? "yellow" : "green"
1991
+ };
1992
+ }
1993
+ return /* @__PURE__ */ jsxs6(Text6, { children: [
1994
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: mode === "insert" ? "cyan" : mode === "token" ? "yellow" : "white", children: [
1995
+ " ",
1996
+ MODE_LABEL[mode],
1997
+ " "
1998
+ ] }),
1999
+ /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: "\u2502 " }),
2000
+ /* @__PURE__ */ jsx7(Text6, { color: connColor, children: connectionLabel(connection, reconnectAt, reconnectAttempt) }),
2001
+ /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 " }),
2002
+ /* @__PURE__ */ jsx7(Text6, { color: tokenSeg.color, children: tokenSeg.text }),
2003
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2004
+ " \u2502 seq ",
2005
+ lastSeq ?? "\u2014"
2006
+ ] }),
2007
+ error ? (
2008
+ // BL-222: an error replaces the full hint line but must never strand
2009
+ // the user — keep the exit hint visible alongside it
2010
+ /* @__PURE__ */ jsxs6(Fragment, { children: [
2011
+ /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
2012
+ " \u2502 ",
2013
+ sanitizeForTty(error)
2014
+ ] }),
2015
+ /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 q quit" })
2016
+ ] })
2017
+ ) : /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 c channels \xB7 i compose \xB7 r reconnect \xB7 Esc normal \xB7 q quit" })
2018
+ ] });
2019
+ }
2020
+
2021
+ // src/ui/TokenPrompt.tsx
2022
+ import { Box as Box5, Text as Text7 } from "ink";
2023
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2024
+ function TokenPrompt({
2025
+ value,
2026
+ reason,
2027
+ webUrl,
2028
+ jwtTemplate
2029
+ }) {
2030
+ const masked = value.length === 0 ? "" : value.length <= 16 ? value : `${value.slice(0, 12)}\u2026 (${value.length} chars)`;
2031
+ return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2032
+ /* @__PURE__ */ jsx8(Text7, { bold: true, color: "yellow", children: "token needed" }),
2033
+ reason ? /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: sanitizeForTty(reason) }) : null,
2034
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2035
+ "mint one: open ",
2036
+ webUrl,
2037
+ " logged in, browser console:"
2038
+ ] }),
2039
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: ` await window.Clerk.session.getToken({template:'${jwtTemplate}'})` }),
2040
+ /* @__PURE__ */ jsxs7(Text7, { children: [
2041
+ /* @__PURE__ */ jsx8(Text7, { color: "yellow", children: "> " }),
2042
+ /* @__PURE__ */ jsx8(Text7, { children: masked }),
2043
+ /* @__PURE__ */ jsx8(Text7, { inverse: true, children: " " })
2044
+ ] }),
2045
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "paste, then Enter \xB7 Ctrl+C quits" })
2046
+ ] });
2047
+ }
2048
+
2049
+ // src/app.tsx
2050
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2051
+ function cleanInput(input) {
2052
+ return input.replace(/\r\n|[\r\n]/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
2053
+ }
2054
+ function App({ store: store2 }) {
2055
+ const { exit } = useApp();
2056
+ const { isRawModeSupported } = useStdin();
2057
+ const interactive = isRawModeSupported === true;
2058
+ const state = useSyncExternalStore(store2.subscribe, store2.getState, store2.getState);
2059
+ useEffect(() => {
2060
+ store2.init();
2061
+ }, [store2]);
2062
+ useInput(
2063
+ (input, key) => {
2064
+ store2.noteUserActivity();
2065
+ if (key.ctrl && input === "c") {
2066
+ store2.quit();
2067
+ exit();
2068
+ return;
2069
+ }
2070
+ const s = store2.getState();
2071
+ if (s.mode === "token") {
2072
+ if (key.return) store2.submitToken();
2073
+ else if (key.backspace || key.delete) store2.setTokenPrompt(s.tokenPrompt.slice(0, -1));
2074
+ else if (key.escape) {
2075
+ if (s.channels.length > 0) store2.setMode("normal");
2076
+ } else if (input) store2.setTokenPrompt(s.tokenPrompt + input.replace(/\s+/g, ""));
2077
+ return;
2078
+ }
2079
+ if (s.mode === "insert") {
2080
+ if (key.return) store2.sendDraft();
2081
+ else if (key.escape) store2.setMode("normal");
2082
+ else if (key.backspace || key.delete) store2.setDraft(s.draft.slice(0, -1));
2083
+ else if (input) store2.setDraft(s.draft + cleanInput(input));
2084
+ return;
2085
+ }
2086
+ if (s.mode === "switcher") {
2087
+ const filtered = filterChannels(s.channels, s.switcherFilter);
2088
+ const filterEmpty = s.switcherFilter.length === 0;
2089
+ if (key.escape) store2.setMode("normal");
2090
+ else if (key.return) {
2091
+ if (filterEmpty) store2.setMode("normal");
2092
+ else if (filtered[0]) store2.openChannel(filtered[0].id);
2093
+ } else if (key.backspace || key.delete) {
2094
+ store2.setSwitcherFilter(s.switcherFilter.slice(0, -1));
2095
+ } else if (filterEmpty && /^[1-9]$/.test(input)) {
2096
+ const ch = filtered[Number(input) - 1];
2097
+ if (ch) store2.openChannel(ch.id);
2098
+ } else if (input) {
2099
+ store2.setSwitcherFilter(s.switcherFilter + cleanInput(input));
2100
+ }
2101
+ return;
2102
+ }
2103
+ if (input === "q") {
2104
+ store2.quit();
2105
+ exit();
2106
+ } else if (input === "c") store2.setMode("switcher");
2107
+ else if (input === "i" || key.return) store2.setMode("insert");
2108
+ else if (input === "r") store2.reconnectNow();
2109
+ },
2110
+ { isActive: interactive }
2111
+ );
2112
+ const activeChannel = state.channels.find((c) => c.id === state.activeChannelId);
2113
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2114
+ /* @__PURE__ */ jsx9(
2115
+ Header,
2116
+ {
2117
+ channelName: activeChannel?.name ?? "(connecting\u2026)",
2118
+ channelKind: activeChannel?.kind ?? "standard",
2119
+ members: state.members,
2120
+ presence: state.presence
2121
+ }
2122
+ ),
2123
+ /* @__PURE__ */ jsx9(MessageList, { messages: state.messages }),
2124
+ state.mode === "switcher" ? /* @__PURE__ */ jsx9(
2125
+ ChannelSwitcher,
2126
+ {
2127
+ channels: state.channels,
2128
+ activeChannelId: state.activeChannelId,
2129
+ filter: state.switcherFilter
2130
+ }
2131
+ ) : null,
2132
+ state.mode === "token" ? /* @__PURE__ */ jsx9(
2133
+ TokenPrompt,
2134
+ {
2135
+ value: state.tokenPrompt,
2136
+ reason: state.tokenPromptReason,
2137
+ webUrl: store2.instance.webUrl,
2138
+ jwtTemplate: store2.instance.clerkJwtTemplate
2139
+ }
2140
+ ) : null,
2141
+ /* @__PURE__ */ jsx9(ActivityLine, { activities: state.activities }),
2142
+ /* @__PURE__ */ jsx9(Composer, { draft: state.draft, focused: state.mode === "insert" }),
2143
+ /* @__PURE__ */ jsx9(
2144
+ StatusBar,
2145
+ {
2146
+ mode: state.mode,
2147
+ connection: state.connection,
2148
+ reconnectAt: state.reconnectAt,
2149
+ reconnectAttempt: state.reconnectAttempt,
2150
+ lastSeq: state.lastSeq,
2151
+ tokenType: state.tokenType,
2152
+ tokenObtainedAt: state.tokenObtainedAt,
2153
+ tokenRefreshable: state.tokenRefreshable,
2154
+ clerkRefresh: state.clerkRefresh,
2155
+ error: state.error
2156
+ }
2157
+ ),
2158
+ !interactive ? /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: "(non-interactive terminal: keys disabled \u2014 tail is read-only here)" }) : null
2159
+ ] });
2160
+ }
2161
+
2162
+ // src/index.tsx
2163
+ import { jsx as jsx10 } from "react/jsx-runtime";
2164
+ function helpText(profileName2, base2, effective) {
2165
+ const relayOverridden = effective.relayUrl !== base2.relayUrl;
2166
+ const overrideNote = relayOverridden ? `
2167
+ ACTIVE OVERRIDE: ${effective.relayUrl}
2168
+ (token-mint instructions below still describe the profile's own
2169
+ instance \u2014 a JWT minted there will NOT work against the override)` : "";
2170
+ return `arp-tui \u2014 tail + post for Agent Relay Protocol channels (as the HUMAN)
2171
+
2172
+ usage: arp-tui [tail|login|logout|status] [--profile <name>] [--relay <url>] [--token <jwt>]
2173
+
2174
+ commands:
2175
+ tail live channel tail + post (default)
2176
+ login sign in once via your browser (OAuth PKCE, BL-218-s1) \u2014
2177
+ stores refreshable tokens in ~/.arp-tui/credentials.json
2178
+ logout revoke the OAuth grant and delete this instance's stored tokens
2179
+ status show the stored credential state for the active instance
2180
+
2181
+ options:
2182
+ --profile <name> instance profile from ~/.arp-tui/config.json
2183
+ (active: ${profileName2}; also ARP_TUI_PROFILE env)
2184
+ --relay <url> relay base URL override (profile value: ${base2.relayUrl};
2185
+ also ARP_TUI_RELAY env)${overrideNote}
2186
+ --token <jwt> Clerk session JWT (interim auth; also ARP_TUI_TOKEN env)
2187
+ -h, --help this text
2188
+
2189
+ environment (advanced):
2190
+ ARP_TUI_CLERK_FAPI override the profile's Clerk Frontend-API host
2191
+ ARP_TUI_CLERK_CLIENT_TOKEN EXPERIMENTAL self-refresh client token (see README)
2192
+
2193
+ token minting (fallback \u2014 prefer \`arp-tui login\`; session JWTs live ~60s):
2194
+ 1. open ${base2.webUrl} and sign in
2195
+ 2. browser console: await window.Clerk.session.getToken({template:'${base2.clerkJwtTemplate}'})
2196
+ 3. export ARP_TUI_TOKEN='<paste>' (or paste in-app when prompted)
2197
+
2198
+ never paste an arp_ak_ agent key \u2014 the TUI is the human, not a bot.`;
2199
+ }
2200
+ function parseCliArgs(argv) {
2201
+ let parsed;
2202
+ try {
2203
+ parsed = nodeParseArgs({
2204
+ args: argv,
2205
+ options: {
2206
+ profile: { type: "string" },
2207
+ relay: { type: "string" },
2208
+ token: { type: "string" },
2209
+ help: { type: "boolean", short: "h" }
2210
+ },
2211
+ allowPositionals: true
2212
+ });
2213
+ } catch (err) {
2214
+ console.error(`${err instanceof Error ? err.message : String(err)} \u2014 try --help`);
2215
+ process.exit(1);
2216
+ }
2217
+ return {
2218
+ cmd: parsed.positionals[0] ?? "tail",
2219
+ profile: parsed.values.profile,
2220
+ relay: parsed.values.relay,
2221
+ token: parsed.values.token,
2222
+ help: parsed.values.help === true
2223
+ };
2224
+ }
2225
+ var args = parseCliArgs(process.argv.slice(2));
2226
+ if (args.help) {
2227
+ try {
2228
+ assertStorePerms();
2229
+ const cfg = loadConfig();
2230
+ const { name, base: base2, instance: instance2 } = resolveProfile(cfg, {
2231
+ profile: args.profile,
2232
+ relayUrl: args.relay
2233
+ });
2234
+ console.log(helpText(name, base2, instance2));
2235
+ } catch (err) {
2236
+ console.log(helpText(DEFAULT_PROFILE_NAME, DEV_PROFILE, DEV_PROFILE));
2237
+ console.error(`
2238
+ \u26A0 config problem (shown with built-in dev defaults above):
2239
+ ${err instanceof Error ? err.message : String(err)}`);
2240
+ }
2241
+ process.exit(0);
2242
+ }
2243
+ try {
2244
+ assertStorePerms();
2245
+ } catch (err) {
2246
+ console.error(err instanceof Error ? err.message : String(err));
2247
+ process.exit(1);
2248
+ }
2249
+ var profileName;
2250
+ var base;
2251
+ var instance;
2252
+ try {
2253
+ const cfg = loadConfig();
2254
+ ({ name: profileName, base, instance } = resolveProfile(cfg, {
2255
+ profile: args.profile,
2256
+ relayUrl: args.relay
2257
+ }));
2258
+ } catch (err) {
2259
+ console.error(err instanceof Error ? err.message : String(err));
2260
+ process.exit(1);
2261
+ }
2262
+ if (args.cmd === "login") {
2263
+ try {
2264
+ const { credentials: credentials2, email } = await login(instance);
2265
+ const who = email ? ` as ${email}` : "";
2266
+ const until = credentials2.expiresAt ? ` (access token refreshes automatically; current one expires ${credentials2.expiresAt})` : "";
2267
+ console.error(`\u2713 logged in${who} for ${instance.relayUrl}${until}`);
2268
+ try {
2269
+ const res = await fetch(`${instance.relayUrl}/auth/me`, {
2270
+ headers: { Authorization: `Bearer ${credentials2.token}` },
2271
+ signal: AbortSignal.timeout(15e3)
2272
+ });
2273
+ if (!res.ok) {
2274
+ const text = (await res.text()).trim().slice(0, 300);
2275
+ console.error(`\u26A0 the relay rejected your profile sync: ${text || `HTTP ${res.status}`}`);
2276
+ }
2277
+ } catch {
2278
+ }
2279
+ console.error("run `arp-tui` to start the tail.");
2280
+ process.exit(0);
2281
+ } catch (err) {
2282
+ console.error(err instanceof Error ? err.message : String(err));
2283
+ process.exit(1);
2284
+ }
2285
+ } else if (args.cmd === "logout") {
2286
+ try {
2287
+ const removed = await logout(instance);
2288
+ process.exit(removed ? 0 : 1);
2289
+ } catch (err) {
2290
+ console.error(err instanceof Error ? err.message : String(err));
2291
+ process.exit(1);
2292
+ }
2293
+ } else if (args.cmd === "status") {
2294
+ const stored = loadStoredCredentials(instance.relayUrl);
2295
+ console.log(`profile: ${profileName}`);
2296
+ console.log(`relay: ${instance.relayUrl}`);
2297
+ if (!stored) {
2298
+ console.log("tokens: none stored \u2014 run `arp-tui login` (or paste a JWT via --token)");
2299
+ process.exit(1);
2300
+ }
2301
+ console.log(`tokens: ${stored.tokenType} (obtained ${stored.obtainedAt})`);
2302
+ const fresh = !needsRefresh(stored.expiresAt);
2303
+ if (stored.expiresAt) {
2304
+ console.log(`access: expires ${stored.expiresAt}${fresh ? "" : " \u2014 STALE (refreshes on next use)"}`);
2305
+ }
2306
+ if (stored.tokenType === "oauth") {
2307
+ const bound = boundToFapi(stored.clerkFapiUrl, instance.clerkFapiUrl);
2308
+ const refreshLine = !stored.refreshToken ? "NO refresh token \u2014 re-login when the access token expires" : bound ? "refresh token stored \u2014 sessions self-renew" : `refresh token stored but bound to ${stored.clerkFapiUrl} \u2014 WITHHELD under the active FAPI override (sessions will NOT self-renew)`;
2309
+ console.log(`refresh: ${refreshLine}`);
2310
+ if (bound && fresh) {
2311
+ const user = await fetchUserInfo(oauthEndpoints(instance.clerkFapiUrl), stored.token);
2312
+ if (user) console.log(`identity: ${user.email ?? user.sub}${user.name ? ` (${user.name})` : ""}`);
2313
+ }
2314
+ }
2315
+ process.exit(0);
2316
+ } else if (args.cmd !== "tail") {
2317
+ console.error(`unknown command "${args.cmd}" \u2014 try --help.`);
2318
+ process.exit(1);
2319
+ }
2320
+ var credentials = null;
2321
+ try {
2322
+ credentials = resolveCredentials(instance, args.token);
2323
+ } catch (err) {
2324
+ console.error(err instanceof Error ? err.message : String(err));
2325
+ process.exit(1);
2326
+ }
2327
+ if (credentials?.tokenType === "dev-jwt" && credentials.token && !credentials.clerkClientToken && !credentials.refreshToken) {
2328
+ console.error(
2329
+ "\u26A0 dev token: Clerk session JWTs expire in ~60s. The live tail (websocket) survives expiry; REST calls (posting) will re-prompt for a fresh token in-app."
2330
+ );
2331
+ }
2332
+ var store = createStore({ instance, credentials });
2333
+ render(/* @__PURE__ */ jsx10(App, { store }));