elanous 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/{8894-7317998f6cb97d8c.js → 8894-e263b332208e3957.js} +1 -1
  6. package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-f4d2ecbbae6088d6.js → page-35e2713597dd24d1.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/app/board/{page-2053e49930c82748.js → page-918b656f064577ba.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-d4b6df905f185055.js → page-f972fc1c00a62e88.js} +1 -1
  11. package/apps/pwa/out/_next/static/chunks/app/bots/{page-a70ba27c33003dd0.js → page-5f30e00693527ebd.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/chat/page-7fbae173b3c103a7.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/consult/{page-18166f885d831d3e.js → page-3b4edcce364062ea.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/control/{page-1c9d6cfe68b121c2.js → page-c5794f484c251f44.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/exec/{page-147a328753d983e2.js → page-016e2bda04eecb1f.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/field/{page-799395a9df7d95aa.js → page-b4d6d9a3b56f2940.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/intake/{page-6a8158b71d1fe9d7.js → page-ec49a466121233ba.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/market/{page-c0c0d19d51342d3e.js → page-8b0402a46a749a3d.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-2e002ad8faa43291.js → page-037f83e2a1edc8e2.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/morning/{page-b672e02e4bec8deb.js → page-adfbd2ba9598e59a.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-9db2226f38c542fb.js → page-0cf318e9dcee2bbf.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/{page-70ee65f5f7a151dc.js → page-1a8e4807292f6b17.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-092e1bcf27d751cc.js → page-de68e2205794037d.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-eb52580c4b75b905.js → page-c71c33a573230672.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-7464c1987dd76e6d.js → page-0df30e0c7e50148a.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/setup/{page-085847b088675978.js → page-4647be5dbcbd0795.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/share/{page-9fe9d38c36999eb4.js → page-e025892bda74b743.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/tasks/page-369846d2921af584.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/term/{page-295a02b1026b8255.js → page-72cbc1c8cdd0f65d.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/trace/page-77b28bfb0996d5ea.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-90670589b87abea8.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-2e367075ffdbc062.js → page-7df9243ed021a4cc.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-647ed2b66c1d49f1.js → page-4498c3853f4f688d.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
  47. package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
  48. package/apps/pwa/out/approvals/index.html +1 -1
  49. package/apps/pwa/out/approvals/index.txt +30 -29
  50. package/apps/pwa/out/autopilot/index.html +1 -1
  51. package/apps/pwa/out/autopilot/index.txt +30 -29
  52. package/apps/pwa/out/board/index.html +1 -1
  53. package/apps/pwa/out/board/index.txt +30 -29
  54. package/apps/pwa/out/botlab/index.html +1 -1
  55. package/apps/pwa/out/botlab/index.txt +27 -26
  56. package/apps/pwa/out/bots/index.html +1 -1
  57. package/apps/pwa/out/bots/index.txt +27 -26
  58. package/apps/pwa/out/chat/index.html +1 -1
  59. package/apps/pwa/out/chat/index.txt +27 -26
  60. package/apps/pwa/out/consult/index.html +1 -1
  61. package/apps/pwa/out/consult/index.txt +30 -29
  62. package/apps/pwa/out/control/index.html +1 -1
  63. package/apps/pwa/out/control/index.txt +30 -29
  64. package/apps/pwa/out/design-check/index.html +1 -1
  65. package/apps/pwa/out/design-check/index.txt +30 -29
  66. package/apps/pwa/out/editor/index.html +1 -1
  67. package/apps/pwa/out/editor/index.txt +30 -29
  68. package/apps/pwa/out/exec/index.html +1 -1
  69. package/apps/pwa/out/exec/index.txt +30 -29
  70. package/apps/pwa/out/field/index.html +1 -1
  71. package/apps/pwa/out/field/index.txt +30 -29
  72. package/apps/pwa/out/healthz +1 -1
  73. package/apps/pwa/out/index.html +1 -1
  74. package/apps/pwa/out/index.txt +27 -26
  75. package/apps/pwa/out/intake/index.html +1 -1
  76. package/apps/pwa/out/intake/index.txt +30 -29
  77. package/apps/pwa/out/live/index.html +1 -1
  78. package/apps/pwa/out/live/index.txt +30 -29
  79. package/apps/pwa/out/market/index.html +1 -1
  80. package/apps/pwa/out/market/index.txt +27 -26
  81. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  82. package/apps/pwa/out/missions/placeholder/index.txt +27 -26
  83. package/apps/pwa/out/morning/index.html +1 -1
  84. package/apps/pwa/out/morning/index.txt +30 -29
  85. package/apps/pwa/out/observatory/index.html +1 -1
  86. package/apps/pwa/out/observatory/index.txt +30 -29
  87. package/apps/pwa/out/ops/checklist/index.html +1 -0
  88. package/apps/pwa/out/ops/checklist/index.txt +30 -0
  89. package/apps/pwa/out/ops/release/index.html +1 -0
  90. package/apps/pwa/out/ops/release/index.txt +30 -0
  91. package/apps/pwa/out/reflection/index.html +1 -1
  92. package/apps/pwa/out/reflection/index.txt +28 -27
  93. package/apps/pwa/out/scheduler/index.html +1 -1
  94. package/apps/pwa/out/scheduler/index.txt +30 -29
  95. package/apps/pwa/out/sessions/index.html +1 -1
  96. package/apps/pwa/out/sessions/index.txt +27 -26
  97. package/apps/pwa/out/settings/devices/index.html +1 -1
  98. package/apps/pwa/out/settings/devices/index.txt +30 -29
  99. package/apps/pwa/out/settings/index.html +1 -1
  100. package/apps/pwa/out/settings/index.txt +30 -29
  101. package/apps/pwa/out/setup/done/index.html +1 -1
  102. package/apps/pwa/out/setup/done/index.txt +30 -29
  103. package/apps/pwa/out/setup/index.html +1 -1
  104. package/apps/pwa/out/setup/index.txt +30 -29
  105. package/apps/pwa/out/share/index.html +1 -1
  106. package/apps/pwa/out/share/index.txt +30 -29
  107. package/apps/pwa/out/showroom/index.html +1 -1
  108. package/apps/pwa/out/showroom/index.txt +27 -26
  109. package/apps/pwa/out/tasks/index.html +1 -1
  110. package/apps/pwa/out/tasks/index.txt +30 -29
  111. package/apps/pwa/out/term/index.html +1 -1
  112. package/apps/pwa/out/term/index.txt +30 -29
  113. package/apps/pwa/out/trace/index.html +1 -1
  114. package/apps/pwa/out/trace/index.txt +30 -29
  115. package/apps/pwa/out/vault/index.html +1 -1
  116. package/apps/pwa/out/vault/index.txt +30 -29
  117. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  118. package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
  119. package/apps/pwa/out/workflows/index.html +1 -1
  120. package/apps/pwa/out/workflows/index.txt +30 -29
  121. package/apps/pwa/out/workspace/index.html +1 -1
  122. package/apps/pwa/out/workspace/index.txt +30 -29
  123. package/apps/pwa/out/worktrees/index.html +1 -1
  124. package/apps/pwa/out/worktrees/index.txt +30 -29
  125. package/graphs/release/recipes.yaml +3 -0
  126. package/graphs/release/release-loop.yaml +3 -1
  127. package/graphs/test-diet/nightly-audit.yaml +18 -0
  128. package/graphs/test-diet/recipes.yaml +3 -0
  129. package/package.json +1 -1
  130. package/plugins/doc-draft/graphs/run-step.ts +1 -1
  131. package/scripts/release-loop/cutoff.ts +20 -2
  132. package/scripts/release-loop/export-check-node.ts +85 -0
  133. package/scripts/release-loop/gate-node.ts +18 -4
  134. package/scripts/release-loop/node-verdict.ts +8 -1
  135. package/scripts/test-diet/lib.ts +1 -0
  136. package/scripts/test-diet/node.ts +56 -14
  137. package/src/auth/setup-link-tokens.fixture.ts +19 -0
  138. package/src/auth/setup-link-tokens.ts +153 -0
  139. package/src/budget/unified-usage.ts +75 -0
  140. package/src/channel-bot-token.ts +37 -10
  141. package/src/chat/index.ts +9 -9
  142. package/src/cli/op-proxy-sync.ts +83 -0
  143. package/src/cli/pr-cli.ts +6 -2
  144. package/src/cli/release-cli.ts +41 -1
  145. package/src/dashboard/dashboard-status-lines.ts +45 -0
  146. package/src/dashboard/first-screen-band.ts +35 -0
  147. package/src/dashboard/index.ts +70 -13
  148. package/src/dashboard/provider-slash-runtime.ts +28 -11
  149. package/src/dashboard/slash-runtime/dashboard-handlers.ts +10 -0
  150. package/src/dashboard/turn-stream-formatter.ts +1 -1
  151. package/src/decisions/decision-cards.ts +1 -1
  152. package/src/index.ts +16 -4
  153. package/src/maturity/feature-maturity.ts +2 -0
  154. package/src/nexus/api/http-server.ts +20 -0
  155. package/src/nexus/api/meta-api.ts +27 -0
  156. package/src/nexus/api/operator.ts +61 -0
  157. package/src/nexus/api/ops-api.ts +139 -0
  158. package/src/nexus/api/ops-seats-sources.ts +65 -0
  159. package/src/nexus/api/ops-seats.ts +114 -0
  160. package/src/nexus/api/public-routes.ts +14 -1
  161. package/src/nexus/api/setup-claim.ts +21 -0
  162. package/src/nexus/config/secrets/file-backend.ts +56 -18
  163. package/src/onboarding/first-run-doctor.ts +54 -0
  164. package/src/release-loop/checklist.ts +5 -64
  165. package/src/release-loop/feature-store.ts +327 -0
  166. package/src/release-loop/manifest.ts +59 -5
  167. package/src/seat-dispatch/ceo-commands.ts +65 -0
  168. package/src/skills/tools/usage-slash.ts +8 -7
  169. package/src/telegram-commands.ts +16 -0
  170. package/src/telegram-run.ts +83 -13
  171. package/src/thinking-line.ts +28 -20
  172. package/src/tui/chat/execution-badge.ts +1 -1
  173. package/src/ux-sim/turn-states.ts +1 -1
  174. package/src/version/packed-revision.json +1 -1
  175. package/apps/pwa/out/_next/static/chunks/4836-3084c58c1725a40b.js +0 -1
  176. package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
  177. package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +0 -1
  178. package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +0 -1
  179. package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +0 -1
  180. package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +0 -1
  181. package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +0 -1
  182. package/apps/pwa/out/_next/static/chunks/app/live/page-e7ed3d010153d818.js +0 -1
  183. package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +0 -1
  184. package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +0 -1
  185. package/apps/pwa/out/_next/static/chunks/app/settings/page-e573437b0a1c728f.js +0 -1
  186. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +0 -1
  187. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +0 -1
  188. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +0 -1
  189. package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +0 -1
  190. package/apps/pwa/out/_next/static/chunks/app/vault/page-82efa177a30f3741.js +0 -1
  191. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +0 -1
  192. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +0 -1
  193. package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +0 -1
  194. package/apps/pwa/out/_next/static/lywc8m_HDzH9MKSDQhyEj/_buildManifest.js +0 -1
  195. /package/apps/pwa/out/_next/static/{lywc8m_HDzH9MKSDQhyEj → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
@@ -0,0 +1,153 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { dlopen, FFIType } from 'bun:ffi';
5
+ import { getElanousConfigDir } from '../elanous-config-dir.js';
6
+
7
+ export const SETUP_LINK_TTL_MS = 10 * 60_000;
8
+ const SETUP_BEARER_TTL_MS = 24 * 60 * 60_000;
9
+ const LINK_PREFIX = 'els_';
10
+ const BEARER_PREFIX = 'elsb_';
11
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
12
+ const LINK_PATTERN = /^els_[A-Za-z0-9_-]{43}$/;
13
+ const BEARER_PATTERN = /^elsb_[A-Za-z0-9_-]{43}$/;
14
+
15
+ interface SetupLinkEntry {
16
+ hash: string;
17
+ expiresAt: string;
18
+ usedAt?: string;
19
+ bearerHash?: string;
20
+ bearerExpiresAt?: string;
21
+ }
22
+
23
+ type ClaimResult =
24
+ | { ok: true; bearer: string; expiresAt: string }
25
+ | { ok: false; reason: 'unknown' | 'used' | 'expired' };
26
+
27
+ function storePath(dir: string): string { return join(dir, 'setup-link-tokens.json'); }
28
+ function hashOf(token: string): string { return createHash('sha256').update(token).digest('hex'); }
29
+ function mint(prefix: string): string { return `${prefix}${randomBytes(32).toString('base64url')}`; }
30
+
31
+ function readStore(dir: string): SetupLinkEntry[] {
32
+ let raw: string;
33
+ try {
34
+ const path = storePath(dir);
35
+ const st = lstatSync(path);
36
+ if (!st.isFile() || (st.mode & 0o077) !== 0) throw new Error('setup link store must be a private regular file (0600)');
37
+ raw = readFileSync(path, 'utf8');
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
40
+ throw error;
41
+ }
42
+ const parsed: unknown = JSON.parse(raw);
43
+ if (!parsed || typeof parsed !== 'object' || !('tokens' in parsed) || !Array.isArray(parsed.tokens)) {
44
+ throw new Error('setup link store is malformed');
45
+ }
46
+ const entries: unknown[] = parsed.tokens;
47
+ if (!entries.every((entry) => {
48
+ if (!entry || typeof entry !== 'object') return false;
49
+ const t = entry as Partial<SetupLinkEntry>;
50
+ return typeof t.hash === 'string' && HASH_PATTERN.test(t.hash)
51
+ && typeof t.expiresAt === 'string' && Number.isFinite(Date.parse(t.expiresAt))
52
+ && (t.usedAt === undefined || (typeof t.usedAt === 'string' && Number.isFinite(Date.parse(t.usedAt))))
53
+ && (t.bearerHash === undefined || (typeof t.bearerHash === 'string' && HASH_PATTERN.test(t.bearerHash)))
54
+ && (t.bearerExpiresAt === undefined || (typeof t.bearerExpiresAt === 'string' && Number.isFinite(Date.parse(t.bearerExpiresAt))))
55
+ && ((t.usedAt === undefined && t.bearerHash === undefined && t.bearerExpiresAt === undefined)
56
+ || (t.usedAt !== undefined && t.bearerHash !== undefined && t.bearerExpiresAt !== undefined));
57
+ })) throw new Error('setup link store is malformed');
58
+ return entries as SetupLinkEntry[];
59
+ }
60
+
61
+ function writeStore(dir: string, tokens: SetupLinkEntry[]): void {
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ const path = storePath(dir);
64
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
65
+ try {
66
+ writeFileSync(tmp, JSON.stringify({ tokens }, null, 2), { flag: 'wx', mode: 0o600 });
67
+ renameSync(tmp, path);
68
+ } finally {
69
+ try { unlinkSync(tmp); } catch (error) {
70
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
71
+ }
72
+ }
73
+ }
74
+
75
+ // A persistent inode and an OS advisory lock avoid stale owner files after an abrupt process exit.
76
+ // Never unlink this file: a replacement inode would let two processes hold different locks.
77
+ const libc = dlopen(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', {
78
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
79
+ });
80
+ const LOCK_EX = 2;
81
+ const LOCK_NB = 4;
82
+ const LOCK_UN = 8;
83
+
84
+ function withLock<T>(dir: string, fn: () => T): T {
85
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
86
+ const lock = `${storePath(dir)}.lock`;
87
+ const fd = openSync(lock, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
88
+ try {
89
+ const stat = fstatSync(fd);
90
+ const current = lstatSync(lock);
91
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.ino !== current.ino || stat.dev !== current.dev) {
92
+ throw new Error('setup link lock must be a private regular file (0600)');
93
+ }
94
+ const until = Date.now() + 3000;
95
+ while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
96
+ if (Date.now() > until) throw new Error('setup link store is locked — try again');
97
+ Bun.sleepSync(20);
98
+ }
99
+ try {
100
+ const locked = lstatSync(lock);
101
+ if (stat.ino !== locked.ino || stat.dev !== locked.dev) throw new Error('setup link lock changed');
102
+ return fn();
103
+ } finally {
104
+ libc.symbols.flock(fd, LOCK_UN);
105
+ }
106
+ } finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+
111
+ /** The plaintext link token is returned once; only its SHA-256 digest is persisted. */
112
+ export function issueSetupLinkToken(opts: { dir?: string; now?: number } = {}): { token: string; expiresAt: string } {
113
+ const dir = opts.dir ?? getElanousConfigDir();
114
+ const now = opts.now ?? Date.now();
115
+ const token = mint(LINK_PREFIX);
116
+ const expiresAt = new Date(now + SETUP_LINK_TTL_MS).toISOString();
117
+ withLock(dir, () => writeStore(dir, [...readStore(dir), { hash: hashOf(token), expiresAt }]));
118
+ return { token, expiresAt };
119
+ }
120
+
121
+ /** Claim exactly once under the same lock used by issuance. Expired and used links retain their diagnostic status. */
122
+ export function claimSetupLinkToken(token: string, opts: { dir?: string; now?: number } = {}): ClaimResult {
123
+ if (typeof token !== 'string' || !LINK_PATTERN.test(token)) return { ok: false, reason: 'unknown' };
124
+ const dir = opts.dir ?? getElanousConfigDir();
125
+ const now = opts.now ?? Date.now();
126
+ return withLock(dir, () => {
127
+ const entries = readStore(dir);
128
+ const digest = Buffer.from(hashOf(token), 'hex');
129
+ const hit = entries.find((entry) => timingSafeEqual(digest, Buffer.from(entry.hash, 'hex')));
130
+ if (!hit) return { ok: false, reason: 'unknown' };
131
+ if (hit.usedAt) return { ok: false, reason: 'used' };
132
+ if (Date.parse(hit.expiresAt) <= now) return { ok: false, reason: 'expired' };
133
+ const bearer = mint(BEARER_PREFIX);
134
+ const expiresAt = new Date(now + SETUP_BEARER_TTL_MS).toISOString();
135
+ hit.usedAt = new Date(now).toISOString();
136
+ hit.bearerHash = hashOf(bearer);
137
+ hit.bearerExpiresAt = expiresAt;
138
+ writeStore(dir, entries);
139
+ return { ok: true, bearer, expiresAt };
140
+ });
141
+ }
142
+
143
+ /** A setup bearer is not an owner or temp-owner token; validate it only on setup-allowed routes. */
144
+ export function matchSetupBearer(bearer: string, opts: { dir?: string; now?: number } = {}): { expiresAt: string } | null {
145
+ if (typeof bearer !== 'string' || !BEARER_PATTERN.test(bearer)) return null;
146
+ let entries: SetupLinkEntry[];
147
+ try { entries = readStore(opts.dir ?? getElanousConfigDir()); } catch { return null; }
148
+ const digest = Buffer.from(hashOf(bearer), 'hex');
149
+ const now = opts.now ?? Date.now();
150
+ const hit = entries.find((entry) => entry.bearerHash && entry.bearerExpiresAt
151
+ && timingSafeEqual(digest, Buffer.from(entry.bearerHash, 'hex')) && Date.parse(entry.bearerExpiresAt) > now);
152
+ return hit?.bearerExpiresAt ? { expiresAt: hit.bearerExpiresAt } : null;
153
+ }
@@ -18,6 +18,7 @@ import { listCodexAccountsInStore } from '../oauth/codex-account-store.js';
18
18
  import { loadTokens } from '../oauth/store.js';
19
19
  import { envLiteral } from '../platform/env-literal.js';
20
20
  import { describeResetCreditExpiry } from './codex-reset-credit-state.js';
21
+ import { visibleWidth } from '../tui.js';
21
22
  import { listCodexResetCredits, type CodexResetCreditsResult } from './codex-reset-credits.js';
22
23
  import type {
23
24
  AccountUsageRow,
@@ -268,6 +269,80 @@ async function collectOpenRouterRows(deps: UnifiedUsageDeps): Promise<AccountUsa
268
269
  }
269
270
  }
270
271
 
272
+ export function formatUsageCompact(report: UnifiedUsageReport, options: { width: number; nowMs: number }): string[] {
273
+ return report.rows.map((row) => {
274
+ const credits = row.credits;
275
+ const subscription = row.subscription;
276
+ const remaining = subscription.status === 'available' && Number.isFinite(subscription.remainingPercent)
277
+ ? subscription.remainingPercent
278
+ : credits.status === 'ok' && credits.usedPercent !== null && Number.isFinite(credits.usedPercent)
279
+ ? Math.round((100 - credits.usedPercent) * 10) / 10
280
+ : null;
281
+ const period = subscription.status === 'available'
282
+ ? subscription.windowKind
283
+ : credits.status === 'ok' ? credits.periodType : null;
284
+ const reset = subscription.status === 'available' ? subscription.resetsAt
285
+ : credits.status === 'ok' && credits.periodEnd ? Date.parse(credits.periodEnd) : Number.NaN;
286
+ const periodLabel = period ? `${formatCompactPeriod(period)} ` : '';
287
+ const when = period === 'prepaid-usd' ? '' : !isValidEpochMs(reset)
288
+ ? ` · ${periodLabel}시각 모름`
289
+ : reset <= options.nowMs
290
+ ? ` · ${periodLabel}재조회 필요`
291
+ : ` · ${periodLabel}${formatCompactReset(reset, options.nowMs)}에 다시 참`;
292
+ const balance = credits.status === 'ok' && credits.balance !== null && Number.isFinite(credits.balance)
293
+ ? ` · 크레딧 ${new Intl.NumberFormat('en-US', { maximumFractionDigits: credits.balance >= 1_000 ? 0 : 1 }).format(credits.balance)}` : '';
294
+ const prefix = `${row.provider} `;
295
+ const name = row.accountName.replace(/[\r\n\t\x00-\x1f\x7f]/g, ' ');
296
+ const remainingLabel = `남은 ${remaining === null ? '잔량 모름' : `${remaining}%`}`;
297
+ const width = Number.isFinite(options.width) ? Math.max(0, Math.floor(options.width)) : 120;
298
+ const suffix = ` · ${remainingLabel}${when}${balance}`;
299
+ const available = width - visibleWidth(prefix) - visibleWidth(suffix);
300
+ if (available >= 1) {
301
+ const account = visibleWidth(name) <= available ? name
302
+ : available === 1 ? '…' : `${compactSlice(name, available - 1)}…`;
303
+ return `${prefix}${account}${suffix}`;
304
+ }
305
+ // The fixed labels can exceed very narrow widths even with no account name.
306
+ // Keep the measured value when possible rather than slicing its digits.
307
+ const essential = `${row.provider} · ${remainingLabel}`;
308
+ if (visibleWidth(essential) <= width) return essential;
309
+ if (visibleWidth(remainingLabel) <= width) return remainingLabel;
310
+ const value = remaining === null ? '잔량 모름' : `${remaining}%`;
311
+ if (visibleWidth(value) <= width) return value;
312
+ return width > 0 ? '…' : '';
313
+ });
314
+ }
315
+
316
+ function formatCompactPeriod(value: string): string {
317
+ const period = value.replace(/^USAGE_PERIOD_TYPE_/, '').toLowerCase();
318
+ switch (period) {
319
+ case 'weekly': return '주간';
320
+ case 'monthly': return '월간';
321
+ case 'daily': return '일간';
322
+ default: return period;
323
+ }
324
+ }
325
+
326
+ function formatCompactReset(reset: number, nowMs: number): string {
327
+ const remaining = reset - nowMs;
328
+ if (remaining < 86_400_000) {
329
+ if (remaining < 3_600_000) return `${Math.ceil(remaining / 60_000)}분 뒤`;
330
+ return `${Math.ceil(remaining / 3_600_000)}시간 뒤`;
331
+ }
332
+ const date = new Date(reset);
333
+ const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
334
+ return `${date.getMonth() + 1}/${date.getDate()} (${weekdays[date.getDay()]}) ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
335
+ }
336
+
337
+ function compactSlice(text: string, width: number): string {
338
+ let result = '';
339
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)) {
340
+ if (visibleWidth(result + segment) > width) break;
341
+ result += segment;
342
+ }
343
+ return result;
344
+ }
345
+
271
346
  export function formatUnifiedUsage(report: UnifiedUsageReport): string {
272
347
  const lines: string[] = [
273
348
  'usage — 계정마다 한 행 · 크레딧 축과 구독 축은 각각 칸',
@@ -1,35 +1,62 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { debug } from './debug/log.js';
4
- import { getSecret, setSecretAsync } from './nexus/config/secrets/index.js';
5
- import { userConfigPath } from './nexus/config/paths.js';
4
+ import { getSecret, setSecretAsync, currentBackendId } from './nexus/config/secrets/index.js';
5
+ import { userConfigPath, secretsPath } from './nexus/config/paths.js';
6
6
  import { isSecretRef, makeSecretRef, secretIdFromRef } from './nexus/config/types.js';
7
7
  import { acquireLockAsync } from './storage/file-lock.js';
8
8
  import type { UserConfig } from './user-config.js';
9
9
 
10
10
  export type ChannelBotPlatform = 'telegram' | 'discord';
11
- export type ChannelBotTokenSource = 'tokenRef' | 'env' | 'plaintext';
11
+ export type ChannelBotTokenSource = 'tokenRef' | 'env' | 'botToken' | 'channels.main';
12
+ export type ChannelBotTokenResolution = { token: string; source: ChannelBotTokenSource; reason?: never }
13
+ | { token: ''; source?: never; reason: string };
12
14
 
13
15
  /** The tab reference is read from the same persistent config as the NEXUS switch. */
14
16
  export function resolveChannelBotToken(
15
17
  platform: ChannelBotPlatform,
16
18
  cfg: Pick<UserConfig, 'telegram' | 'discord'> & { tabs?: Record<string, { tokenRef?: unknown }> },
17
- ): { token: string; source: ChannelBotTokenSource } | undefined {
19
+ ): ChannelBotTokenResolution {
20
+ let reason: string | undefined;
18
21
  const ref = cfg.tabs?.[`${platform}:1`]?.tokenRef;
19
22
  if (isSecretRef(ref)) {
20
23
  const id = secretIdFromRef(ref);
21
- const token = id ? getSecret(id) : undefined;
22
- if (token) return { token, source: 'tokenRef' };
24
+ if (id) {
25
+ const backend = currentBackendId();
26
+ if (backend !== 'file') {
27
+ reason = `backend ${backend} not sync-readable`;
28
+ } else {
29
+ const token = getSecret(id);
30
+ if (token) {
31
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'tokenRef' });
32
+ return { token, source: 'tokenRef' };
33
+ }
34
+ reason = `ref-id-missing ${id} in ${secretsPath()}`;
35
+ }
36
+ }
23
37
  }
24
38
  const envName = platform === 'telegram' ? 'ELANOUS_TELEGRAM_BOT_TOKEN' : 'ELANOUS_DISCORD_BOT_TOKEN';
25
39
  const envToken = process.env[envName];
26
- if (envToken) return { token: envToken, source: 'env' };
40
+ if (envToken) {
41
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'env' });
42
+ return { token: envToken, source: 'env' };
43
+ }
27
44
  const token = cfg[platform]?.botToken;
28
45
  if (token) {
29
- debug.log('channel-bot.token', 'plaintext-fallback', { platform });
30
- return { token, source: 'plaintext' };
46
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'botToken' });
47
+ return { token, source: 'botToken' };
48
+ }
49
+ if (platform === 'telegram') {
50
+ const channels = cfg.telegram?.channels;
51
+ const channelToken = (channels?.find(c => c.name === 'main') ?? channels?.[0])?.botToken;
52
+ if (channelToken) {
53
+ debug.log('channel-bot.token', 'resolved', { kind: platform, source: 'channels.main' });
54
+ return { token: channelToken, source: 'channels.main' };
55
+ }
31
56
  }
32
- return undefined;
57
+ reason ??= 'no-source';
58
+ debug.log('channel-bot.token', 'unresolved', { kind: platform, reason });
59
+ return { token: '', reason };
33
60
  }
34
61
 
35
62
  /** Publish the secret before its reference, under the config lock. Other config keys survive unchanged. */
package/src/chat/index.ts CHANGED
@@ -431,7 +431,14 @@ export interface SlashCommand {
431
431
 
432
432
  // Default commands available in the input
433
433
  export const SLASH_COMMANDS: SlashCommand[] = [
434
- { name: 'directive', aliases: [], description: 'Record a directive as a Linear issue' },
434
+ { name: 'help', aliases: ['?'], description: '명령과 키 보기' },
435
+ { name: 'resume', aliases: [], description: '지난 대화 이어 가기 · /resume <id 앞자리>' },
436
+ { name: 'model', aliases: ['m'], description: '모델 바꾸기 — /model <codex|terra|sol|luna|opus|sonnet|grok> (list + current when no arg). OpenAI 는 Codex(Responses API)만. effort 는 /reasoning.', subcommands: ['codex', 'terra', 'sol', 'luna', 'opus', 'sonnet', 'grok'] },
437
+ { name: 'clear', aliases: ['cls'], description: '대화 기록 지우기' },
438
+ { name: 'status', aliases: ['st'], description: '지금 상태 — 모델 · 연결 · 계정' },
439
+ { name: 'remaining', aliases: [], description: '계정별 남은 사용량' },
440
+ { name: 'setup', aliases: [], description: '상세 설정 — /setup · /setup reset(다음 실행 때 마법사 다시)', subcommands: ['help', 'reset'] },
441
+ { name: 'quit', aliases: ['q', 'exit'], description: '나가기' },
435
442
  { name: 'run-skill', aliases: ['rs', 'run'], description: 'Execute a skill via its SKILL.md prompt' },
436
443
  { name: 'ad', aliases: [], description: 'Create an advertising plan from a URL, brief, or attached image' },
437
444
  // B4 — craft rulebook verdict, same resolution as `elanous repo design-check`
@@ -440,10 +447,8 @@ export const SLASH_COMMANDS: SlashCommand[] = [
440
447
  { name: 'design', aliases: ['design-check'], description: 'Design rulebooks this repository declares · /design [--declared] · /design pick [number|id] to choose a design direction', subcommands: ['--declared', 'pick'] },
441
448
  { name: 'provider', aliases: ['p'], description: 'LLM providers — /provider (list) · next (cycle, also Alt+M / pill click) · use <name> · pick (visual picker) · reset', subcommands: ['next', 'use', 'pick', 'picker', 'menu', 'reset', 'list'] },
442
449
  { name: 'reasoning', aliases: ['r', 'think'], description: 'Reasoning level (codex effort + summary, anthropic extended-thinking budget) — /reasoning [off|low|medium|high|xhigh] (cycle when no arg · xhigh 는 모델 상한이 xhigh 이상일 때만 wire 에 실린다, 아니면 high 로 깎인다)', subcommands: ['off', 'low', 'medium', 'high', 'xhigh'] },
443
- { name: 'model', aliases: ['m'], description: 'Switch active model — /model <codex|terra|sol|luna|opus|sonnet|grok> (list + current when no arg). OpenAI 는 Codex(Responses API)만. effort 는 /reasoning.', subcommands: ['codex', 'terra', 'sol', 'luna', 'opus', 'sonnet', 'grok'] },
444
450
  { name: 'local', aliases: ['ll'], description: 'Local OpenAI-compatible LLM — ping / models / test / use', subcommands: ['ping', 'models', 'test', 'use', 'status'] },
445
451
  { name: 'session', aliases: ['sess'], description: 'Session resume — list / load / sync / new (handoff from mobile)', subcommands: ['list', 'load', 'sync', 'new'] },
446
- { name: 'resume', aliases: [], description: 'Pick a past session, or load one by id prefix · /resume <id-prefix>' },
447
452
  { name: 'fork', aliases: [], description: 'Copy this session into a new one' },
448
453
  { name: 'rewind', aliases: [], description: 'Rewind — 과거 user 턴 픽커로 되감기 (원본 보존 · 새 세션 분기 · /rewind <n> 숏컷)' },
449
454
  // TUI 부활 C-a (2026-07-12): autopilot 미션 TUI 표면 — CLI 동등 단일 창구 소비.
@@ -454,12 +459,8 @@ export const SLASH_COMMANDS: SlashCommand[] = [
454
459
  { name: 'sync', aliases: ['s'], description: 'Enter sync mode' },
455
460
  { name: 'plugin', aliases: ['plugins'], description: 'Manage plugins', subcommands: ['list', 'activate', 'deactivate', 'reload'] },
456
461
  { name: 'widget', aliases: ['widgets'], description: 'Manage widgets', subcommands: ['list', 'reload', 'instances'] },
457
- { name: 'clear', aliases: ['cls'], description: 'Clear chat log' },
458
462
  { name: 'log', aliases: [], description: 'Chat Log controls — size / clear / filter / search / freeze / solo / turn / fold / help', subcommands: ['size', 'clear', 'filter', 'search', 'freeze', 'solo', 'turn', 'fold', 'help'] },
459
- { name: 'help', aliases: ['?'], description: 'Show help overlay' },
460
- { name: 'setup', aliases: [], description: 'Onboarding wizard guide — /setup (anchor) · /setup reset (re-run wizard on next boot)', subcommands: ['help', 'reset'] },
461
463
  { name: 'memory', aliases: ['mem'], description: 'Memory ops — list / show / search / add / delete (see `elanous memory --help`)', subcommands: ['list', 'show', 'search', 'add', 'delete', 'index'] },
462
- { name: 'status', aliases: ['st'], description: 'Print the claude-code-style status pills (working dir + git + model)' },
463
464
  { name: 'export', aliases: [], description: 'Export this conversation transcript to a markdown file — /export [path] (default ~/temp/elanous-transcript-<stamp>.md)' },
464
465
  { name: 'delta', aliases: ['diffs'], description: 'Source delta browser — open the latest turn-scoped file diff popup', subcommands: ['open', 'help'] },
465
466
  { name: 'theme', aliases: [], description: 'Theme controls — list / switch / use / reset / preview / export', subcommands: ['list', 'switch', 'use', 'reset', 'preview', 'export'] },
@@ -486,7 +487,6 @@ export const SLASH_COMMANDS: SlashCommand[] = [
486
487
  { name: 'acp', aliases: [], description: 'ACP chat — stream claude-code / codex / gemini replies into the chat pane (not a VW spawn). /acp codex points at the canonical codex app-server path; /acp cas is a synonym.', subcommands: ['claude', 'codex', 'gemini', 'cas', 'cancel', 'status', 'drop'] },
487
488
  { name: 'conv', aliases: [], description: 'Conversation widget/popup host — /conv list · /conv open <session-id> · /conv layout <cascade|tile|stack> · /conv focus <next|prev>.', subcommands: ['list', 'ls', 'open', 'layout', 'focus'] },
488
489
  { name: 'handoff', aliases: [], description: 'H5 P3 cross-agent context handoff: /handoff <from_session_id> <to_brand> [--channels r,p,m] [--prompt "prefix"]. Takes source session snapshot (filtered by channels if observer present) and launches target via adapter registry. Brands: codex · claude · claude-code · gemini · elanous.' },
489
- { name: 'remaining', aliases: [], description: 'What each account has left' },
490
490
  { name: 'agent-room', aliases: [], description: 'H6 P4 VW agent-room — /agent-room <N> <brands...> · /agent-room list · /agent-room close <id> · N ∈ {2,3,4} · brands: codex/claude/gemini/elanous/auto/lll:<m>', subcommands: ['list', 'close', 'preset', 'help'] },
491
491
  { name: 'showroom', aliases: ['sr'], description: 'Showroom v2 multi-LLM lane composer — /showroom (default 2-pane) · /showroom <lane1> <lane2> [<lane3> [<lane4>]] · lane = role:provider[:transport] · roles plan/build/exec/review/reflect · pair with /lane and /relay for cross-lane handoffs.', subcommands: ['help'] },
492
492
  { name: 'reply', aliases: [], description: 'H6 P5 AgentReply — /reply <target-session-id> <message...> · send message to a live session, capture the response · flags: --from · --channels r,m · --idle-ms · --timeout-ms', subcommands: ['help'] },
@@ -503,7 +503,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
503
503
  { name: 'qc', aliases: [], description: 'Quick-control — arm one-shot control mode; next message runs as control, then auto-return to chat.' },
504
504
  { name: 'voice-chat', aliases: ['vc'], description: 'Continuous voice chat mode — speak, elanous replies in voice (Phase 4-5). Subcommands: start / stop / cancel / status. Chord: Alt+R toggles enter/exit anywhere.', subcommands: ['start', 'stop', 'cancel', 'status'] },
505
505
  { name: 'auto-tts', aliases: ['tts', 'autotts'], description: 'Auto-TTS for chat responses (Phase 2) — speaks LLM replies sentence-by-sentence. Subcommands: on / off / toggle / status.', subcommands: ['on', 'off', 'toggle', 'status'] },
506
- { name: 'quit', aliases: ['q', 'exit'], description: 'Exit application' },
506
+ { name: 'directive', aliases: [], description: '(운영) 지시를 Linear 이슈로 기록' },
507
507
  ];
508
508
 
509
509
  /** Every user-visible slash spelling, normalized for runtime-catalog comparison. */
@@ -0,0 +1,83 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ACP_TOKEN_FILE } from '../auth/token-store.js';
5
+ import { debug } from '../debug/log.js';
6
+ import { getElanousConfigDir } from '../elanous-config-dir.js';
7
+ import { userConfigPath } from '../user-config.js';
8
+
9
+ export interface OpProxyConfig {
10
+ host: string;
11
+ bearerFile: string;
12
+ restart: string;
13
+ }
14
+
15
+ /** Read the optional, unnormalized nexus setting from the same config file as the CLI. */
16
+ export function readOpProxyConfig(path = userConfigPath()): OpProxyConfig | undefined {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as { nexus?: { opProxy?: OpProxyConfig } };
19
+ return raw?.nexus?.opProxy;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ type SshExecutor = (argv: string[], input: string) => { status: number | null; error?: Error };
26
+
27
+ export function finishOpProxyRotation(
28
+ proxy: OpProxyConfig | undefined,
29
+ token: string,
30
+ opts: { sync: boolean; ssh?: SshExecutor; tokenFile?: string; out?: (line: string) => void },
31
+ ): boolean | undefined {
32
+ if (!proxy) return undefined;
33
+ if (!opts.sync) {
34
+ (opts.out ?? console.log)('op 프록시 토큰도 갈아야 합니다 — `elanous token rotate --sync-op-proxy`');
35
+ return undefined;
36
+ }
37
+ return syncOpProxyToken(proxy, token, opts);
38
+ }
39
+
40
+ const executeSsh: SshExecutor = (argv, input) => {
41
+ const result = spawnSync('ssh', argv, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000 });
42
+ return { status: result.status, ...(result.error ? { error: result.error } : {}) };
43
+ };
44
+
45
+ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
46
+
47
+ /** Never interpolate the token in a command, diagnostic, or log. Only SSH stdin carries it. */
48
+ export function syncOpProxyToken(
49
+ proxy: OpProxyConfig | undefined,
50
+ token: string,
51
+ opts: { ssh?: SshExecutor; tokenFile?: string; out?: (line: string) => void } = {},
52
+ ): boolean | undefined {
53
+ if (!proxy) return undefined;
54
+ const out = opts.out ?? console.log;
55
+ const host = typeof proxy.host === 'string' ? proxy.host : '';
56
+ const file = typeof proxy.bearerFile === 'string' ? proxy.bearerFile : '';
57
+ const service = typeof proxy.restart === 'string' ? proxy.restart : '';
58
+ const valid = /^[\w][\w.-]*$/.test(host) && file.startsWith('/') && !/[\r\n\0]/.test(file)
59
+ && /^[\w@][\w@.-]*$/.test(service) && !/[\r\n\0]/.test(opts.tokenFile ?? '');
60
+ const remote = `sudo install -m 600 /dev/stdin ${shellQuote(file)} && sudo systemctl restart ${shellQuote(service)}`;
61
+ const manual = valid
62
+ ? `token=$(head -n 1 < ${shellQuote(opts.tokenFile ?? join(getElanousConfigDir(), ACP_TOKEN_FILE))}) && [ -n "$token" ] && printf 'ELANOUS_OP_BEARER=%s\\n' "$token" | ssh ${shellQuote(host)} ${shellQuote(remote)}`
63
+ : 'nexus.opProxy 의 host, bearerFile, restart 를 확인한 뒤 bearer 파일을 갱신하고 서비스를 재시작하세요';
64
+ let reason = '';
65
+ if (!valid) {
66
+ reason = 'nexus.opProxy 설정 오류';
67
+ } else {
68
+ try {
69
+ const result = (opts.ssh ?? executeSsh)([host, remote], `ELANOUS_OP_BEARER=${token}\n`);
70
+ if (result.status === 0 && !result.error) {
71
+ debug.log('auth.op-proxy-sync', 'synced', { host, reason: 'ok' });
72
+ out(`op 프록시 토큰을 바꿨습니다(${host})`);
73
+ return true;
74
+ }
75
+ reason = result.error ? 'ssh 실행 실패' : `ssh 종료 코드 ${result.status ?? '미상'}`;
76
+ } catch {
77
+ reason = 'ssh 실행 실패';
78
+ }
79
+ }
80
+ debug.log('auth.op-proxy-sync', 'failed', { host, reason });
81
+ out(`op 프록시 토큰 동기화 실패(${host}): ${reason} — 직접: ${manual}`);
82
+ return false;
83
+ }
package/src/cli/pr-cli.ts CHANGED
@@ -115,6 +115,8 @@ export interface PrLandDeps {
115
115
  /** iOS 시험 게이트 심(시험 주입용). */
116
116
  runIosGate?: (out: { log: (message: string) => void; error: (message: string) => void }, changedFiles: readonly string[]) => boolean;
117
117
  isInteractive?: () => boolean;
118
+ /** Clock for the recent-landings window (test seam · default Date.now). */
119
+ now?: () => number;
118
120
  requestConfirmation?: (opts: ConfirmOpts) => Promise<ConfirmResult>;
119
121
  overlapConfirmChannels?: ConfirmChannel[];
120
122
  overlapConfirmTimeoutMs?: number;
@@ -822,11 +824,12 @@ function emitRecentLandingRateAdvisory(
822
824
  cwd: string,
823
825
  commits: readonly { subject: string; authorEmail: string; committedAtMs: number }[] | null,
824
826
  out: { log: (message: string) => void },
827
+ now: () => number = Date.now,
825
828
  ): boolean {
826
829
  const windowMinutes = RECENT_LANDING_WINDOW_MINUTES;
827
830
  const authorEmail = currentAuthorEmail(run, cwd);
828
831
  if (!authorEmail || commits === null) {
829
- const previous = commits === null ? undefined : previousLandingSummary(commits, Date.now());
832
+ const previous = commits === null ? undefined : previousLandingSummary(commits, now());
830
833
  record('recent-landing-rate', true, {
831
834
  recentCount: 0,
832
835
  windowMinutes,
@@ -836,7 +839,7 @@ function emitRecentLandingRateAdvisory(
836
839
  });
837
840
  return false;
838
841
  }
839
- const nowMs = Date.now();
842
+ const nowMs = now();
840
843
  const recentCount = countRecentLandings(commits, {
841
844
  authorEmail,
842
845
  nowMs,
@@ -1077,6 +1080,7 @@ export async function runPrLand(opts: PrLandOpts = {}, deps: PrLandDeps = {}): P
1077
1080
  cwd,
1078
1081
  collectResolvedBaseLandings(run, cwd, DEFAULT_GRANULARITY_SINCE, base),
1079
1082
  out,
1083
+ deps.now,
1080
1084
  );
1081
1085
 
1082
1086
  // ⛔ local-path / no-remote 는 gh 가 원리상 못 한다. 이미 아는 자리에서 멈춘다.
@@ -24,6 +24,7 @@ import { getElanousConfigDirOverride } from '../elanous-config-dir.js';
24
24
  import { envLiteral } from '../platform/env-literal.js';
25
25
  import { userConfigPath } from '../user-config.js';
26
26
  import { addItem, devVersion, listChecklist, removeItem, seedFromRoadmap, setItem, summarizeChecklist, type ChecklistStatus, type ChecklistDisposition, type ChecklistKind } from '../release-loop/checklist.js';
27
+ import * as features from '../release-loop/feature-store.js';
27
28
  import { writeStdoutJson } from './stdout-json.js';
28
29
  import { runUnattendedRelease, type UnattendedReleaseDeps } from '../../scripts/release-loop/unattended-release.js';
29
30
  import { releaseReadiness } from '../../scripts/release-loop/release-readiness.js';
@@ -492,7 +493,7 @@ export function registerReleaseCommands(program: Command, releaseRunDeps: Unatte
492
493
  .option('--version <v>', '판 또는 별칭(기본: package.json 의 개발판에서 -dev.N 제거)')
493
494
  .option('--json', '결과 JSON');
494
495
  const context = (cmd: Command) => {
495
- const opts = { ...(cmd.parent?.opts() as { version?: string; json?: boolean }), ...(cmd.opts() as { version?: string; json?: boolean }) };
496
+ const opts = { ...(cmd.parent?.parent?.opts() as { version?: string; json?: boolean }), ...(cmd.parent?.opts() as { version?: string; json?: boolean }), ...(cmd.opts() as { version?: string; json?: boolean }) };
496
497
  const codenames = checklistCodenames();
497
498
  return { version: checklistVersion(opts.version, codenames), codenames, json: opts.json };
498
499
  };
@@ -533,6 +534,45 @@ export function registerReleaseCommands(program: Command, releaseRunDeps: Unatte
533
534
  const data = seedFromRoadmap(version, readFileSync(opts.from, 'utf8'));
534
535
  if (json) console.log(JSON.stringify(data)); else console.log(`✅ ${version} ${data.items.length}칸`);
535
536
  });
537
+ const actor = () => process.env.ELANOUS_TRACK || 'cli';
538
+ withContext(checklist.command('move <id>').description('칸을 한 트랜잭션으로 다른 판에 옮긴다'))
539
+ .requiredOption('--from <v>', '현재 판').requiredOption('--to <v>', '새 판')
540
+ .action((id: string, opts: { from: string; to: string }, cmd: Command) => {
541
+ const { codenames, json } = context(cmd);
542
+ const from = checklistVersion(opts.from, codenames), to = checklistVersion(opts.to, codenames);
543
+ const data = features.move(id, from, to, actor());
544
+ if (json) console.log(JSON.stringify(data)); else console.log(`✅ ${id} ${from} → ${to}`);
545
+ });
546
+ withContext(checklist.command('retitle <id> <title>').description('칸 제목 수정')).action((id: string, title: string, _opts: unknown, cmd: Command) => {
547
+ const { version, json } = context(cmd);
548
+ const snapshot = listChecklist(version);
549
+ features.retitle(id, title, actor(), snapshot.released, snapshot.dev);
550
+ if (json) console.log(JSON.stringify(listChecklist(version))); else console.log(`✅ ${id} 제목 수정`);
551
+ });
552
+ const evidence = checklist.command('evidence').description('칸의 PR·커밋 근거');
553
+ withContext(evidence.command('add <id> <ref>').description('근거 참조 추가')).action((id: string, ref: string, _opts: unknown, cmd: Command) => {
554
+ const { version, json } = context(cmd);
555
+ const snapshot = listChecklist(version);
556
+ features.evidenceAdd(id, version, ref, actor(), snapshot.released, snapshot.dev);
557
+ if (json) console.log(JSON.stringify(features.history(id))); else console.log(`✅ ${id} 근거 ${ref}`);
558
+ });
559
+ withContext(checklist.command('history <id>').description('칸의 판 이동·변경 이력')).action((id: string, _opts: unknown, cmd: Command) => {
560
+ const { json } = context(cmd);
561
+ const rows = features.history(id);
562
+ if (json) { console.log(JSON.stringify(rows)); return; }
563
+ const info = features.details(id);
564
+ if (info) {
565
+ console.log(`${info.id} · ${info.title} · 담당 ${info.owner ?? '-'} · 종류 ${info.kind ?? '-'} · 처음 ${info.createdAt}`);
566
+ for (const ev of info.evidence) console.log(` 근거 ${ev.version} ${ev.ref} (${ev.at} ${ev.by})`);
567
+ }
568
+ for (const row of rows) console.log(`${row.at} ${row.version} ${row.by} ${row.field}: ${JSON.stringify(row.from)} → ${JSON.stringify(row.to)}`);
569
+ });
570
+ withContext(checklist.command('export').description('SQLite 에서 checklist.json 스냅샷 생성')).action((_opts: unknown, cmd: Command) => {
571
+ const { version, json } = context(cmd);
572
+ const snapshot = listChecklist(version);
573
+ const data = features.exportJson(version, snapshot.released, snapshot.dev);
574
+ if (json) console.log(JSON.stringify(data)); else console.log(`✅ ${version} checklist.json 내보냄`);
575
+ });
536
576
  release.command('prepare')
537
577
  .description('깨끗한 원본 → 공개본 → 공개 저장소 이력 위 커밋 → PWA → 묶음·체크섬 → 로컬 끝까지 (네트워크 쓰기 없음)')
538
578
  .requiredOption('--version <x.y.z>', 'package.json 과 같은 버전(먼저 버전 PR 을 착지)')
@@ -0,0 +1,45 @@
1
+ export interface DashboardStatusLinesInput {
2
+ readonly provider?: string | null;
3
+ readonly model?: string | null;
4
+ readonly reasoning?: string | null;
5
+ readonly daemonAddress?: string | null;
6
+ readonly daemonConnected: boolean;
7
+ readonly accountName?: string | null;
8
+ readonly sessionSurfaceLines: readonly string[];
9
+ readonly view: string;
10
+ readonly focus: string;
11
+ readonly cwd: string;
12
+ readonly preview: string;
13
+ readonly starterClosed: string;
14
+ readonly chatOnly: string;
15
+ readonly acp: string;
16
+ }
17
+
18
+ export function buildDashboardStatusLines(input: DashboardStatusLinesInput): string[] {
19
+ const known = (value?: string | null): string => value?.trim() || '모름';
20
+ // A remote URL can contain credentials or query parameters. Neither belongs in /status.
21
+ let address = '모름';
22
+ if (input.daemonAddress?.trim()) {
23
+ try {
24
+ const url = new URL(input.daemonAddress);
25
+ address = `${url.protocol}//${url.host}`;
26
+ } catch {
27
+ address = '모름';
28
+ }
29
+ }
30
+ return [
31
+ `모델: ${known(input.provider)}/${known(input.model)} (${known(input.reasoning)})`,
32
+ `연결: 데몬 ${address} ${input.daemonConnected ? '붙음' : '안 붙음'}`,
33
+ `계정: ${input.accountName?.trim() === 'default' ? '기본' : known(input.accountName)}`,
34
+ '',
35
+ '자세히',
36
+ ...input.sessionSurfaceLines.map(line => ` ${line}`),
37
+ ` view: ${input.view}`,
38
+ ` focus: ${input.focus}`,
39
+ ` cwd: ${input.cwd}`,
40
+ ` preview: ${input.preview}`,
41
+ ` starterClosed: ${input.starterClosed}`,
42
+ ` chatOnly: ${input.chatOnly}`,
43
+ ` acp: ${input.acp}`,
44
+ ];
45
+ }
@@ -0,0 +1,35 @@
1
+ export interface FirstScreenBandInput {
2
+ readonly width: number;
3
+ readonly daemon: boolean;
4
+ readonly pwa?: {
5
+ readonly tailnet?: string | null;
6
+ readonly lan?: string | null;
7
+ readonly loopback?: string | null;
8
+ };
9
+ }
10
+
11
+ /** A startup transcript band; never includes credentials or a partial address. */
12
+ export function buildFirstScreenBand(input: FirstScreenBandInput): string[] {
13
+ const width = Number.isFinite(input.width) ? Math.max(0, Math.floor(input.width)) : 0;
14
+ const fit = (text: string): string => text.slice(0, width);
15
+ const address = [input.pwa?.tailnet, input.pwa?.lan, input.pwa?.loopback]
16
+ .map((candidate) => {
17
+ if (!candidate) return null;
18
+ try {
19
+ const url = new URL(candidate);
20
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) return null;
21
+ return `${url.origin}${url.pathname === '/app/' ? '/app/' : ''}`;
22
+ } catch {
23
+ return null;
24
+ }
25
+ })
26
+ .find((candidate) => candidate !== null);
27
+ const fallback = input.pwa === undefined ? 'PWA: not checked' : 'PWA: unavailable';
28
+ const addressLine = address ? `PWA: ${address}` : fallback;
29
+
30
+ return [
31
+ fit(`elanous | daemon: ${input.daemon ? 'online' : 'offline'}`),
32
+ addressLine.length <= width ? addressLine : fit(address ? 'PWA: address available' : fallback),
33
+ fit('Type a message to begin | /help for commands'),
34
+ ];
35
+ }