ever-terminal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xiang Tan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # Ever Terminal
2
+
3
+ `ever-terminal` is designed to be used with the Even App.
4
+
5
+ > Building glasses-native apps instead of mirroring your laptop? See [@evenrealities/even_hub_sdk](https://www.npmjs.com/package/@evenrealities/even_hub_sdk).
6
+
7
+ Supports macOS, Linux, Windows.
8
+
9
+ ---
10
+
11
+ ## Requirements
12
+
13
+ - **Node.js 18+** — check with `node --version`. Install from [nodejs.org](https://nodejs.org), or `brew install node` (macOS), or your distro's package manager (Linux).
14
+ - An **Even Realities G2** + **R1 ring**, paired through the Even app (iOS/Android).
15
+ - Optional but recommended: a [Tailscale](https://tailscale.com) account signed in on both your laptop and phone — gives you a stable private network without needing the public-tunnel providers below.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install -g ever-terminal
23
+ ```
24
+
25
+ Verify the install:
26
+
27
+ ```bash
28
+ ever-terminal --version
29
+ ```
30
+
31
+ Update to the latest release:
32
+
33
+ ```bash
34
+ npm install -g ever-terminal@latest
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Quick Start
40
+
41
+ ```bash
42
+ ever-terminal
43
+ ```
44
+
45
+ By default the server starts on `http://localhost:3456`.
46
+
47
+ Useful startup options:
48
+
49
+ ```bash
50
+ ever-terminal --cwd /path/to/project
51
+ ever-terminal --port 8080
52
+ ever-terminal --token mytoken123
53
+ ever-terminal --name my-laptop
54
+ ever-terminal --provider codex
55
+ ever-terminal --expose pinggy
56
+ ever-terminal --expose bore
57
+ ever-terminal --expose ngrok
58
+ ```
59
+
60
+ ---
61
+
62
+ ## How it works
63
+
64
+ ever-terminal runs a local HTTP server on `:3456` (configurable via `--port`), spawns your AI agent (Claude Code or Codex) as a child process, captures its streaming output, renders it onto the G2's 576×288 canvas, and translates R1 ring gestures back into keyboard events for the agent.
65
+
66
+ The Even app connects to your laptop over your chosen transport. By default it binds to your detected LAN address (same Wi-Fi). Pass `--tailscale` to bind on your Tailscale tailnet instead (stable across networks, end-to-end WireGuard), `-i <interface>` to pin a specific network interface, or `--expose pinggy` / `--expose bore` / `--expose ngrok` to open a temporary public tunnel.
67
+
68
+ ```
69
+ ┌──────────────────┐
70
+ │ your laptop │
71
+ [ claude / codex ] ─│ ever-terminal │
72
+ │ :3456 │
73
+ └────────┬─────────┘
74
+
75
+ ┌───────────────┴───────────────┐
76
+ │ transport (pick one) │
77
+ │ default: LAN (same Wi-Fi) │
78
+ │ --tailscale │
79
+ │ --expose pinggy │
80
+ │ --expose bore │
81
+ │ --expose ngrok │
82
+ └───────────────┬───────────────┘
83
+
84
+ ┌────────┴─────────┐
85
+ │ Even app │
86
+ └──┬────────────┬──┘
87
+ (display) (input)
88
+ BLE ↓ ↑ BLE
89
+ ┌────┴────┐ ┌────┴────┐
90
+ │ G2 │ │ R1 │
91
+ │ 576×288 │ │ ring │
92
+ └─────────┘ └─────────┘
93
+ ```
94
+
95
+ The agent is *your* binary running on *your* laptop. ever-terminal is a renderer + input bridge, not a runtime.
96
+
97
+ ---
98
+
99
+ ## CLI
100
+
101
+ ```text
102
+ ever-terminal [command] [options]
103
+
104
+ Commands:
105
+ ever-terminal start
106
+ ever-terminal complete <shell>
107
+
108
+ Local network options:
109
+ --tailscale
110
+ -i, --interface, --if <name>
111
+
112
+ Quick public expose options:
113
+ --expose <provider> Quick public expose provider (`pinggy`, `bore`, `ngrok`)
114
+
115
+ Options:
116
+ -p, --port <n>
117
+ -t, --token <str>
118
+ -n, --name <str>
119
+ -d, --cwd <path>
120
+ --provider <name> claude, codex (default: claude)
121
+ --log-file [path]
122
+ --verbose
123
+ -h, --help
124
+ -v, --version
125
+
126
+ Examples:
127
+ ever-terminal
128
+ ever-terminal -p 8080
129
+ ever-terminal -t mytoken123
130
+ ever-terminal --expose pinggy
131
+ ever-terminal --expose ngrok
132
+ ```
133
+
134
+ Quick public expose helpers are intended for simple temporary sharing, not long-term use.
135
+ For stable setups, prefer a proper network path such as Tailscale or a production tunnel configuration.
136
+
137
+ Current quick expose providers:
138
+
139
+ ### pinggy
140
+
141
+ No install required — uses your system's SSH client. Just pass `--expose pinggy`.
142
+
143
+ Behind the scenes:
144
+
145
+ ```bash
146
+ ssh -p 443 -R0:localhost:<port> a.pinggy.io
147
+ ```
148
+
149
+ Pinggy is a hosted SSH-tunnel service; the public URL appears in your terminal once the tunnel is up.
150
+
151
+ ### bore
152
+
153
+ Self-hostable, written in Rust. GitHub: [ekzhang/bore](https://github.com/ekzhang/bore).
154
+
155
+ Install:
156
+
157
+ - **macOS:** `brew install bore-cli` *(or)* `cargo install bore-cli`
158
+ - **Linux:** `cargo install bore-cli` *(or)* download a prebuilt binary from [releases](https://github.com/ekzhang/bore/releases) and put `bore` on your `$PATH`
159
+ - **Windows:** download `bore-vX.Y.Z-x86_64-pc-windows-msvc.zip` from [releases](https://github.com/ekzhang/bore/releases), extract `bore.exe`, add its folder to your `PATH`
160
+
161
+ Verify with `bore --version`. Then `--expose bore` runs:
162
+
163
+ ```bash
164
+ bore local <port> --to bore.pub
165
+ ```
166
+
167
+ ### ngrok
168
+
169
+ Hosted tunnel service, free tier available. Site: [ngrok.com](https://ngrok.com).
170
+
171
+ Install:
172
+
173
+ - **macOS:** `brew install ngrok/ngrok/ngrok`
174
+ - **Linux:** see the apt/yum repos at [ngrok.com/download](https://ngrok.com/download) — or download the tarball, extract, and put `ngrok` on your `$PATH`
175
+ - **Windows:** download the zip from [ngrok.com/download](https://ngrok.com/download), extract `ngrok.exe`, add its folder to your `PATH`
176
+
177
+ One-time setup — sign up at [ngrok.com](https://ngrok.com), copy your authtoken from the dashboard, then:
178
+
179
+ ```bash
180
+ ngrok config add-authtoken <your-token>
181
+ ```
182
+
183
+ Verify with `ngrok --version`. Then `--expose ngrok` runs:
184
+
185
+ ```bash
186
+ ngrok http <port>
187
+ ```
188
+
189
+ Shell completion (example usage):
190
+
191
+ ```text
192
+ ever-terminal complete zsh
193
+ ever-terminal complete bash
194
+ ever-terminal complete fish
195
+ ever-terminal complete powershell
196
+ ```
197
+
198
+ Each form prints the completion script for that shell.
199
+
200
+
201
+ ## Flow
202
+
203
+ 1. Start the server with `ever-terminal`. It prints a connection URL, a token, and a QR code in the terminal.
204
+ 2. Open the Even app on your phone and scan the QR code — or paste the URL and token manually if scanning isn't convenient.
205
+ 3. Your G2 glasses and R1 ring connect via BLE through the Even app. The agent's output streams onto the glasses, and ring gestures route back to the agent as keyboard events.
206
+
207
+
208
+ ## Providers
209
+
210
+ The server can drive all supported providers concurrently; `--provider <name>`
211
+ just chooses the **default**. Each provider wraps its own CLI — make sure the
212
+ binary is installed and authenticated beforehand.
213
+
214
+ | Provider | Required CLI | Auth | Override path env |
215
+ |------------|-----------------|--------------------------------|--------------------|
216
+ | `claude` | `claude` | `claude login` (Claude Code) | n/a |
217
+ | `codex` | `codex` | `codex` first-run wizard | n/a |
218
+
219
+ ---
220
+
221
+ ## Common problems
222
+
223
+ | **Symptom** | **Cause** | **Fix** |
224
+ |-----------------------------------------------------------|---------------------------------------------------------|------------------------------------------------------------------------------------------|
225
+ | Phone app shows "Server unreachable" | Laptop and phone on different transports | Match: both on Tailscale (`--tailscale`) or both on the same Wi-Fi |
226
+ | `EADDRINUSE :3456` | Another ever-terminal already running | `lsof -i :3456` → kill old one, or pass `--port <other>` |
227
+ | `command not found: claude` or `codex` | Agent binary not on `$PATH` | Install per the Providers table; verify with `which claude` / `which codex` |
228
+ | `--expose pinggy` hangs | Pinggy edge timing out | Switch to `--expose bore`, `--expose ngrok`, or Tailscale |
229
+ | Token rotates every restart | Not passing `--token` | `ever-terminal --token my-fixed-token` |
230
+ | Output truncated mid-stream | Agent printed something the 576×288 layout can't render | Re-run with `--verbose --log-file ./debug.log` and open an issue with the offending line |
231
+
232
+ For anything else: `ever-terminal --verbose --log-file ./debug.log`, reproduce, attach the log to an email to software@evenrealities.com .
233
+
234
+ ---
235
+
236
+ ## Changelog
237
+
238
+ ### 0.8.1
239
+
240
+ - optimize codex session history performance for large session (windows)
241
+ - add `--expose ngrok` support (need to sign in elsewhere first)
242
+
243
+ ### 0.8.0
244
+
245
+ - codex now only starts a background process when necessary
246
+
247
+ ### 0.7.9
248
+
249
+ - add debug timing for codex history api
250
+ - fix middle deny behavior in multiple permission requests
251
+
252
+ ### 0.7.8
253
+
254
+ - add update check api
255
+
256
+ ### 0.7.7
257
+
258
+ - improve codex support with `ever-terminal codex` wrapper which can sync
259
+ messages between codex cli and glasses
260
+ - internal refactoring
261
+
262
+ ---
263
+
264
+ ## License
265
+
266
+ MIT
@@ -0,0 +1,234 @@
1
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { listSessions, getSessionMessages, } from "@anthropic-ai/claude-agent-sdk";
5
+ import { ClaudeSession } from "./session.js";
6
+ /** Find the jsonl file for a Claude session by scanning project dirs. */
7
+ function findSessionFile(sessionId) {
8
+ const claudeDir = join(homedir(), ".claude", "projects");
9
+ if (!existsSync(claudeDir))
10
+ return null;
11
+ for (const dir of readdirSync(claudeDir)) {
12
+ const p = join(claudeDir, dir, `${sessionId}.jsonl`);
13
+ if (existsSync(p))
14
+ return p;
15
+ }
16
+ return null;
17
+ }
18
+ /** Read last line of a jsonl file. */
19
+ function readLastLine(filePath) {
20
+ try {
21
+ const data = readFileSync(filePath, "utf8");
22
+ const lines = data.trimEnd().split("\n");
23
+ if (lines.length === 0)
24
+ return null;
25
+ return JSON.parse(lines[lines.length - 1]);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Determine session status by reading the jsonl file directly. */
32
+ export function claudeSessionStatus(sessionId) {
33
+ const file = findSessionFile(sessionId);
34
+ if (!file)
35
+ return "idle";
36
+ const last = readLastLine(file);
37
+ if (!last)
38
+ return "idle";
39
+ if (last.type === "last-prompt")
40
+ return "idle";
41
+ if (last.type === "system" &&
42
+ (last.subtype === "stop_hook_summary" || last.subtype === "turn_duration"))
43
+ return "idle";
44
+ if (last.type === "assistant" &&
45
+ last.message?.stop_reason &&
46
+ last.message.stop_reason !== "tool_use")
47
+ return "idle";
48
+ if (last.type === "result")
49
+ return "idle";
50
+ if (last.type === "permission-mode")
51
+ return "idle";
52
+ if (last.type === "user") {
53
+ const c = last.message?.content;
54
+ const text = typeof c === "string" ? c : c?.[0]?.text ?? "";
55
+ if (text.includes("Request interrupted by user"))
56
+ return "idle";
57
+ }
58
+ if (last.timestamp) {
59
+ const ageMs = Date.now() - new Date(last.timestamp).getTime();
60
+ if (ageMs > 120_000)
61
+ return "idle";
62
+ }
63
+ return "busy";
64
+ }
65
+ // ── Provider factory ────────────────────────────────────
66
+ export function createClaudeProvider(emit) {
67
+ const sessions = new Map();
68
+ function makeSession(sessionId) {
69
+ if (sessionId) {
70
+ const existing = sessions.get(sessionId);
71
+ if (existing)
72
+ return existing;
73
+ }
74
+ const session = new ClaudeSession(emit);
75
+ session.onIdReady((sid) => {
76
+ if (!sessions.has(sid))
77
+ sessions.set(sid, session);
78
+ });
79
+ if (sessionId)
80
+ sessions.set(sessionId, session);
81
+ return session;
82
+ }
83
+ async function prompt(sessionId, text, cwd) {
84
+ let session;
85
+ if (sessionId)
86
+ session = sessions.get(sessionId);
87
+ if (sessionId && !session && !findSessionFile(sessionId)) {
88
+ throw Object.assign(new Error(`Claude session not found: ${sessionId}`), { statusCode: 404 });
89
+ }
90
+ if (!session) {
91
+ session = makeSession(sessionId);
92
+ await session.start(sessionId, cwd);
93
+ }
94
+ // Emit user_prompt when session ID is known
95
+ session.onIdReady((sid) => {
96
+ emit(sid, { type: "user_prompt", text });
97
+ });
98
+ if (session.busy) {
99
+ session.enqueue(text);
100
+ }
101
+ else {
102
+ session.run(text).catch((err) => {
103
+ console.error(`[claude-provider] run failed: ${err.message}`);
104
+ });
105
+ }
106
+ const resolvedId = session.id ??
107
+ (await session.waitForId(10000).catch(() => null)) ??
108
+ "";
109
+ return { sessionId: resolvedId, provider: "claude" };
110
+ }
111
+ function respondPermission(sessionId, decision) {
112
+ sessions.get(sessionId)?.respondPermission(decision);
113
+ }
114
+ function respondQuestion(sessionId, answer) {
115
+ sessions.get(sessionId)?.respondQuestion(answer);
116
+ }
117
+ function interrupt(sessionId) {
118
+ sessions.get(sessionId)?.interrupt();
119
+ }
120
+ function getStatus(sessionId) {
121
+ const session = sessions.get(sessionId);
122
+ if (!session)
123
+ return null;
124
+ return { state: session.status, provider: "claude" };
125
+ }
126
+ async function getSessionStatus(sessionId) {
127
+ const session = sessions.get(sessionId);
128
+ if (session)
129
+ return session.status;
130
+ return claudeSessionStatus(sessionId);
131
+ }
132
+ async function listClaudeSessions(limit, cwd) {
133
+ const infos = await listSessions(cwd ? { dir: cwd, limit } : { limit });
134
+ return infos.map((info) => ({
135
+ id: info.sessionId,
136
+ title: (info.customTitle ||
137
+ info.summary ||
138
+ info.firstPrompt ||
139
+ "").slice(0, 64),
140
+ timestamp: new Date(info.lastModified).toISOString(),
141
+ cwd: info.cwd || "",
142
+ provider: "claude",
143
+ status: null,
144
+ }));
145
+ }
146
+ async function getInfo() {
147
+ const { exec } = await import("node:child_process");
148
+ const { promisify } = await import("node:util");
149
+ const execAsync = promisify(exec);
150
+ let version = "";
151
+ try {
152
+ const { stdout } = await execAsync("claude --version", {
153
+ timeout: 3000,
154
+ });
155
+ version = stdout.trim().replace(" (Claude Code)", "");
156
+ }
157
+ catch { }
158
+ let account = {};
159
+ let model = "";
160
+ try {
161
+ const recent = await listSessions({ limit: 3 });
162
+ for (const info of recent) {
163
+ const messages = await getSessionMessages(info.sessionId);
164
+ for (let i = messages.length - 1; i >= 0; i--) {
165
+ const entry = messages[i];
166
+ if (entry.type !== "assistant")
167
+ continue;
168
+ const m = entry.message?.model;
169
+ if (m) {
170
+ model = m;
171
+ break;
172
+ }
173
+ }
174
+ if (model)
175
+ break;
176
+ }
177
+ }
178
+ catch { }
179
+ let modelDisplay = "";
180
+ if (model) {
181
+ const parts = model
182
+ .replace("claude-", "")
183
+ .replace(/-\d{8,}$/, "")
184
+ .split("-");
185
+ const name = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
186
+ const ver = parts.slice(1).join(".");
187
+ modelDisplay = name + (ver ? " " + ver : "");
188
+ }
189
+ try {
190
+ const { stdout } = await execAsync("claude auth status", {
191
+ timeout: 5000,
192
+ });
193
+ const auth = JSON.parse(stdout.trim());
194
+ account = {
195
+ email: auth.email ?? "",
196
+ organization: auth.orgName ?? "",
197
+ subscriptionType: auth.subscriptionType ?? "",
198
+ };
199
+ }
200
+ catch { }
201
+ return {
202
+ account,
203
+ model: modelDisplay || "Unknown",
204
+ version: version || "Unknown",
205
+ provider: "claude",
206
+ };
207
+ }
208
+ const MAX_HISTORY_ITEMS = 10;
209
+ async function getHistory(sessionId, limit) {
210
+ const messages = await getSessionMessages(sessionId);
211
+ const reduced = messages.reduce(function (acc, msg) {
212
+ const contents = msg.message?.content;
213
+ for (const content of contents) {
214
+ if (content?.type === "text") {
215
+ acc.push({ role: msg.type, text: content.text });
216
+ }
217
+ }
218
+ return acc;
219
+ }, []);
220
+ const returnCount = Math.min(limit, MAX_HISTORY_ITEMS);
221
+ return reduced.slice(-returnCount);
222
+ }
223
+ return {
224
+ listSessions: listClaudeSessions,
225
+ getSessionStatus,
226
+ getInfo,
227
+ getHistory,
228
+ prompt,
229
+ respondPermission,
230
+ respondQuestion,
231
+ interrupt,
232
+ getStatus,
233
+ };
234
+ }