jaitus 0.3.4 → 0.3.5
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/README.md +33 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +493 -103
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/mcp.js
CHANGED
|
@@ -1,51 +1,127 @@
|
|
|
1
1
|
// ─── Model Context Protocol over stdio ─────────────────────────────────────────
|
|
2
2
|
//
|
|
3
3
|
// Jaitus supports the portable part of MCP that is most useful in a terminal: local
|
|
4
|
-
// stdio servers exposing tools.
|
|
5
|
-
//
|
|
4
|
+
// stdio servers exposing tools.
|
|
5
|
+
//
|
|
6
|
+
// Servers come from two places, merged: your own (~/.jaitus/mcp.json, added with
|
|
7
|
+
// `jaitus mcp add --global`) and the project's (.mcp.json). A project's file comes with
|
|
8
|
+
// the repository — somebody else wrote it — so its servers run only after you have
|
|
9
|
+
// approved that exact command (`jaitus mcp trust`, or the prompt in a session). The
|
|
10
|
+
// previous version started every command in a cloned repo's .mcp.json on the first
|
|
11
|
+
// prompt, without asking, even in plan mode.
|
|
12
|
+
//
|
|
13
|
+
// A server is started once and kept for the session, so a stateful one (a browser, a
|
|
14
|
+
// database connection) keeps its state between calls. Discovery is cached per project
|
|
15
|
+
// until a config file changes.
|
|
6
16
|
|
|
17
|
+
import crypto from 'node:crypto';
|
|
7
18
|
import fs from 'node:fs';
|
|
8
19
|
import path from 'node:path';
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
10
|
-
import { SESSIONS } from './config.js';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
|
+
import { SESSIONS, loadSettings, saveSettings } from './config.js';
|
|
22
|
+
|
|
23
|
+
const GLOBAL_FILE = path.join(path.dirname(SESSIONS), 'mcp.json');
|
|
24
|
+
const projectFile = (cwd) => path.join(cwd, '.mcp.json');
|
|
25
|
+
|
|
26
|
+
function readServers(file) {
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
return data?.mcpServers && typeof data.mcpServers === 'object' ? data.mcpServers : {};
|
|
30
|
+
} catch { return {}; }
|
|
31
|
+
}
|
|
11
32
|
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
/** Every configured server: { name, config, scope: 'global'|'project', file }. Project wins a name clash. */
|
|
34
|
+
export function allServers(cwd) {
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const [name, config] of Object.entries(readServers(GLOBAL_FILE))) out.set(name, { name, config, scope: 'global', file: GLOBAL_FILE });
|
|
37
|
+
for (const [name, config] of Object.entries(readServers(projectFile(cwd)))) out.set(name, { name, config, scope: 'project', file: projectFile(cwd) });
|
|
38
|
+
return [...out.values()];
|
|
14
39
|
}
|
|
15
40
|
|
|
41
|
+
/** Kept for callers that want the old shape. */
|
|
16
42
|
export function readMcpConfig(cwd) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
const servers = {};
|
|
44
|
+
for (const s of allServers(cwd)) servers[s.name] = s.config;
|
|
45
|
+
return { file: projectFile(cwd), servers };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Trust ──
|
|
49
|
+
/** What is approved: the command line itself. Change the command and it asks again. */
|
|
50
|
+
export function fingerprint(config) {
|
|
51
|
+
const body = JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
|
|
52
|
+
return crypto.createHash('sha256').update(body).digest('hex').slice(0, 24);
|
|
53
|
+
}
|
|
54
|
+
const trustKey = (cwd) => path.resolve(cwd).toLowerCase();
|
|
55
|
+
export function isTrusted(cwd, server) {
|
|
56
|
+
if (server.scope === 'global') return true;
|
|
57
|
+
const t = loadSettings().trustedMcp?.[trustKey(cwd)] || {};
|
|
58
|
+
return t[server.name] === fingerprint(server.config);
|
|
59
|
+
}
|
|
60
|
+
export function trustServer(cwd, server) {
|
|
61
|
+
const s = loadSettings();
|
|
62
|
+
const all = { ...(s.trustedMcp || {}) };
|
|
63
|
+
all[trustKey(cwd)] = { ...(all[trustKey(cwd)] || {}), [server.name]: fingerprint(server.config) };
|
|
64
|
+
saveSettings({ ...s, trustedMcp: all });
|
|
65
|
+
discovery.clear();
|
|
66
|
+
}
|
|
67
|
+
export function untrustedServers(cwd) {
|
|
68
|
+
return allServers(cwd).filter((s) => s.config?.command && !isTrusted(cwd, s));
|
|
24
69
|
}
|
|
25
70
|
|
|
26
71
|
export function listMcpServers(cwd) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
72
|
+
return allServers(cwd).map((s) => ({
|
|
73
|
+
name: s.name,
|
|
74
|
+
scope: s.scope,
|
|
75
|
+
trusted: isTrusted(cwd, s),
|
|
76
|
+
transport: s.config?.type || (s.config?.command ? 'stdio' : 'unknown'),
|
|
77
|
+
command: [s.config?.command || s.config?.url || '', ...(s.config?.args || [])].join(' ').trim(),
|
|
32
78
|
}));
|
|
33
79
|
}
|
|
34
80
|
|
|
35
|
-
export function addMcpServer(cwd, name, config) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
81
|
+
export function addMcpServer(cwd, name, config, { global = false } = {}) {
|
|
82
|
+
const file = global ? GLOBAL_FILE : projectFile(cwd);
|
|
83
|
+
const next = { mcpServers: { ...readServers(file), [name]: config } };
|
|
84
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
85
|
+
fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
86
|
+
// You added it yourself: approved.
|
|
87
|
+
if (!global) trustServer(cwd, { name, config, scope: 'project' });
|
|
88
|
+
discovery.clear();
|
|
41
89
|
}
|
|
42
90
|
|
|
43
91
|
export function removeMcpServer(cwd, name) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
92
|
+
for (const file of [projectFile(cwd), GLOBAL_FILE]) {
|
|
93
|
+
const servers = readServers(file);
|
|
94
|
+
if (!servers[name]) continue;
|
|
95
|
+
const { [name]: _, ...rest } = servers;
|
|
96
|
+
fs.writeFileSync(file, `${JSON.stringify({ mcpServers: rest }, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
discovery.clear();
|
|
98
|
+
closeAll();
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Starting a server ──
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* On Windows, spawn without a shell only finds .exe and .com — `npx`, `npm`, `uvx` are
|
|
108
|
+
* .cmd shims and failed with ENOENT (silently: the server just had no tools). The
|
|
109
|
+
* command is looked up on PATH with PATHEXT; a .cmd/.bat is run through cmd.exe with
|
|
110
|
+
* every argument quoted.
|
|
111
|
+
*/
|
|
112
|
+
function resolveSpawn(command, args) {
|
|
113
|
+
if (process.platform !== 'win32') return { file: command, args, opts: {} };
|
|
114
|
+
let full = command;
|
|
115
|
+
if (!/[\\/]/.test(command) || !path.extname(command)) {
|
|
116
|
+
const r = spawnSync('where', [command], { encoding: 'utf8', windowsHide: true });
|
|
117
|
+
const hits = String(r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
118
|
+
full = hits.find((h) => /\.(exe|com)$/i.test(h)) || hits.find((h) => /\.(cmd|bat)$/i.test(h)) || command;
|
|
119
|
+
}
|
|
120
|
+
if (/\.(cmd|bat)$/i.test(full)) {
|
|
121
|
+
const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
|
122
|
+
return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${[q(full), ...args.map(q)].join(' ')}"`], opts: { windowsVerbatimArguments: true } };
|
|
123
|
+
}
|
|
124
|
+
return { file: full, args, opts: {} };
|
|
49
125
|
}
|
|
50
126
|
|
|
51
127
|
class StdioClient {
|
|
@@ -55,25 +131,37 @@ class StdioClient {
|
|
|
55
131
|
this.nextId = 1;
|
|
56
132
|
this.pending = new Map();
|
|
57
133
|
this.buffer = '';
|
|
134
|
+
this.stderr = '';
|
|
135
|
+
this.alive = false;
|
|
58
136
|
}
|
|
59
137
|
|
|
60
138
|
async start() {
|
|
61
139
|
if (!this.config?.command) throw new Error('MCP server has no stdio command');
|
|
62
|
-
|
|
140
|
+
const { file, args, opts } = resolveSpawn(this.config.command, this.config.args || []);
|
|
141
|
+
this.child = spawn(file, args, {
|
|
63
142
|
cwd: this.cwd,
|
|
64
143
|
shell: false,
|
|
65
144
|
env: { ...process.env, ...(this.config.env || {}) },
|
|
66
|
-
stdio: ['pipe', 'pipe', '
|
|
145
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
...opts,
|
|
67
148
|
});
|
|
149
|
+
this.alive = true;
|
|
150
|
+
// A server kept for the session must not keep the PROCESS alive: a one-shot run
|
|
151
|
+
// finished its answer and then never exited. A request in flight holds its own
|
|
152
|
+
// timer, which is what keeps Node running while one is needed.
|
|
153
|
+
try { this.child.unref(); this.child.stdout.unref?.(); this.child.stderr.unref?.(); this.child.stdin.unref?.(); } catch { /* keep them */ }
|
|
68
154
|
this.child.stdout.setEncoding('utf8');
|
|
69
155
|
this.child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
70
|
-
|
|
71
|
-
this.child.on('
|
|
156
|
+
// Kept (the tail) so a failure can say what the server said.
|
|
157
|
+
this.child.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-2000); });
|
|
158
|
+
this.child.on('error', (error) => { this.alive = false; this.rejectAll(error); });
|
|
159
|
+
this.child.on('close', (code) => { this.alive = false; this.rejectAll(new Error(`MCP server exited (${code ?? 'unknown'})${this.stderr.trim() ? `: ${this.stderr.trim().split('\n').pop()}` : ''}`)); });
|
|
72
160
|
await this.request('initialize', {
|
|
73
161
|
protocolVersion: '2024-11-05',
|
|
74
162
|
capabilities: {},
|
|
75
|
-
clientInfo: { name: 'jaitus', version: '0.
|
|
76
|
-
});
|
|
163
|
+
clientInfo: { name: 'jaitus', version: '0.3' },
|
|
164
|
+
}, 20_000);
|
|
77
165
|
this.notify('notifications/initialized', {});
|
|
78
166
|
return this;
|
|
79
167
|
}
|
|
@@ -96,23 +184,24 @@ class StdioClient {
|
|
|
96
184
|
}
|
|
97
185
|
}
|
|
98
186
|
|
|
99
|
-
request(method, params) {
|
|
187
|
+
request(method, params, timeoutMs = 120_000) {
|
|
100
188
|
const id = this.nextId++;
|
|
101
189
|
return new Promise((resolve, reject) => {
|
|
102
190
|
const timer = setTimeout(() => {
|
|
103
191
|
this.pending.delete(id);
|
|
104
|
-
reject(new Error(`MCP request timed out: ${method}`));
|
|
105
|
-
},
|
|
192
|
+
reject(new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${method}`));
|
|
193
|
+
}, timeoutMs);
|
|
106
194
|
this.pending.set(id, {
|
|
107
195
|
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
108
196
|
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
109
197
|
});
|
|
110
|
-
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
198
|
+
try { this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); }
|
|
199
|
+
catch (e) { this.pending.delete(id); clearTimeout(timer); reject(e); }
|
|
111
200
|
});
|
|
112
201
|
}
|
|
113
202
|
|
|
114
203
|
notify(method, params) {
|
|
115
|
-
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
|
|
204
|
+
try { this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); } catch { /* gone */ }
|
|
116
205
|
}
|
|
117
206
|
|
|
118
207
|
rejectAll(error) {
|
|
@@ -121,40 +210,88 @@ class StdioClient {
|
|
|
121
210
|
}
|
|
122
211
|
|
|
123
212
|
close() {
|
|
124
|
-
|
|
213
|
+
this.alive = false;
|
|
214
|
+
try {
|
|
215
|
+
if (process.platform === 'win32' && this.child?.pid) spawnSync('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
216
|
+
else this.child?.kill();
|
|
217
|
+
} catch { /* already gone */ }
|
|
125
218
|
}
|
|
126
219
|
}
|
|
127
220
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
221
|
+
// One live client per server for the session.
|
|
222
|
+
const clients = new Map();
|
|
223
|
+
function closeAll() { for (const client of clients.values()) client.close(); clients.clear(); }
|
|
224
|
+
process.once('exit', closeAll);
|
|
225
|
+
|
|
226
|
+
async function clientFor(key, config, cwd) {
|
|
227
|
+
const have = clients.get(key);
|
|
228
|
+
if (have?.alive) return have;
|
|
229
|
+
const fresh = new StdioClient(config, cwd);
|
|
230
|
+
await fresh.start();
|
|
231
|
+
clients.set(key, fresh);
|
|
232
|
+
return fresh;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A provider rejects every request whose tools list holds an invalid name.
|
|
236
|
+
const safeName = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
237
|
+
|
|
238
|
+
function toolText(response) {
|
|
239
|
+
return (response?.content || []).map((item) => {
|
|
240
|
+
if (item?.type === 'text') return item.text;
|
|
241
|
+
if (item?.type === 'image') return '[the tool returned an image]';
|
|
242
|
+
if (item?.type === 'resource') return item.resource?.text || `[resource ${item.resource?.uri || ''}]`;
|
|
243
|
+
return JSON.stringify(item);
|
|
244
|
+
}).join('\n') || JSON.stringify(response || {});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const discovery = new Map(); // cwd|mtimes -> Promise<tools>
|
|
248
|
+
|
|
249
|
+
function stamp(cwd) {
|
|
250
|
+
return [GLOBAL_FILE, projectFile(cwd)].map((f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } }).join('|');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The tools of every trusted server, in the same permission-aware shape as local tools.
|
|
255
|
+
* Servers are started in parallel, once per session (until a config file changes).
|
|
256
|
+
*/
|
|
257
|
+
export function discoverMcpTools(cwd) {
|
|
258
|
+
const key = `${path.resolve(cwd)}|${stamp(cwd)}|${JSON.stringify(loadSettings().trustedMcp?.[trustKey(cwd)] || {})}`;
|
|
259
|
+
if (!discovery.has(key)) {
|
|
260
|
+
discovery.set(key, (async () => {
|
|
261
|
+
const servers = allServers(cwd).filter((s) => s.config?.command && isTrusted(cwd, s));
|
|
262
|
+
const lists = await Promise.all(servers.map(async (server) => {
|
|
263
|
+
const ckey = `${path.resolve(cwd)}|${server.scope}|${server.name}|${fingerprint(server.config)}`;
|
|
264
|
+
try {
|
|
265
|
+
const client = await clientFor(ckey, server.config, cwd);
|
|
266
|
+
const listed = await client.request('tools/list', {}, 20_000);
|
|
267
|
+
return (listed?.tools || []).map((remote) => ({
|
|
268
|
+
name: `mcp__${safeName(server.name)}__${safeName(remote.name)}`.slice(0, 64),
|
|
269
|
+
description: remote.description || `Tool ${remote.name} from MCP server ${server.name}.`,
|
|
270
|
+
input_schema: remote.inputSchema || { type: 'object', properties: {} },
|
|
271
|
+
needsPermission: true,
|
|
272
|
+
summarise: () => `${server.name}: ${remote.name}`,
|
|
273
|
+
async run(input) {
|
|
274
|
+
const live = await clientFor(ckey, server.config, cwd);
|
|
275
|
+
const timeout = Number(server.config.timeoutMs) > 0 ? Number(server.config.timeoutMs) : 120_000;
|
|
276
|
+
const response = await live.request('tools/call', { name: remote.name, arguments: input || {} }, timeout);
|
|
277
|
+
const text = toolText(response);
|
|
278
|
+
// The server said it failed: a failure, not a result that happens to
|
|
279
|
+
// contain an error message.
|
|
280
|
+
if (response?.isError) throw new Error(text || `${remote.name} failed`);
|
|
281
|
+
return text;
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// Shown once per session instead of silently leaving the server toolless.
|
|
286
|
+
mcpProblems.set(server.name, e.message);
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}));
|
|
290
|
+
return lists.flat();
|
|
291
|
+
})());
|
|
158
292
|
}
|
|
159
|
-
return
|
|
293
|
+
return discovery.get(key);
|
|
160
294
|
}
|
|
295
|
+
|
|
296
|
+
/** Servers that failed to start this session, and why. */
|
|
297
|
+
export const mcpProblems = new Map();
|
package/src/project.js
CHANGED
|
Binary file
|
package/src/session.js
CHANGED
|
@@ -49,6 +49,20 @@ export function deviceId() {
|
|
|
49
49
|
return id;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Tokens go only to the server that issued them. Pointing the CLI elsewhere
|
|
54
|
+
* (`jaitus config baseUrl …`, JAITUS_BASE_URL) used to send the saved access AND refresh
|
|
55
|
+
* tokens to that host — anyone who got a user to set it could take over the account.
|
|
56
|
+
* The login records its server; a different origin needs its own sign-in.
|
|
57
|
+
*/
|
|
58
|
+
export function assertSameServer(creds) {
|
|
59
|
+
if (!creds?.baseUrl) return; // older logins did not record it
|
|
60
|
+
const origin = (u) => { try { return new URL(u).origin.toLowerCase(); } catch { return String(u || '').toLowerCase(); } };
|
|
61
|
+
if (origin(creds.baseUrl) !== origin(endpoints().baseUrl)) {
|
|
62
|
+
throw new JaitusError({ code: 'auth_required', message: `You are signed in to ${origin(creds.baseUrl)}, but Jaitus is pointed at ${origin(endpoints().baseUrl)}. Sign in there with /login, or point it back.` });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
export function deviceName() {
|
|
53
67
|
return os.hostname().slice(0, 60);
|
|
54
68
|
}
|
|
@@ -151,6 +165,7 @@ export async function accessToken({ rejected = null } = {}) {
|
|
|
151
165
|
if (process.env.JAITUS_TOKEN) return process.env.JAITUS_TOKEN.trim();
|
|
152
166
|
const creds = loadCredentials();
|
|
153
167
|
if (!creds?.refreshToken) throw new JaitusError({ code: 'auth_required' });
|
|
168
|
+
assertSameServer(creds);
|
|
154
169
|
if (!rejected && fresh(creds)) return creds.accessToken;
|
|
155
170
|
// Another process may already have renewed: the file holds a different, fresh token.
|
|
156
171
|
if (rejected && fresh(creds) && creds.accessToken !== rejected) return creds.accessToken;
|
|
@@ -190,7 +205,10 @@ export async function signOut() {
|
|
|
190
205
|
if (!creds?.refreshToken && !creds?.accessToken) return { hadSession: false, server: false };
|
|
191
206
|
const { baseUrl } = endpoints();
|
|
192
207
|
let server = false;
|
|
208
|
+
let otherServer = false;
|
|
209
|
+
try { assertSameServer(creds); } catch { otherServer = true; }
|
|
193
210
|
try {
|
|
211
|
+
if (otherServer) throw new Error('pointed at a different server — the tokens are not sent there');
|
|
194
212
|
const res = await fetch(`${baseUrl}/api/auth/cli/logout`, {
|
|
195
213
|
method: 'POST',
|
|
196
214
|
headers: {
|
package/src/sessions.js
CHANGED
|
@@ -46,7 +46,19 @@ export function saveSession(session) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export function loadSession(id) {
|
|
49
|
-
const
|
|
49
|
+
const direct = readSessionFile(fileFor(id));
|
|
50
|
+
if (direct) return direct;
|
|
51
|
+
// The ids shown by `jaitus sessions` and /sessions are 8-character prefixes; resuming
|
|
52
|
+
// one of those used to say "No saved session found". A unique prefix is enough.
|
|
53
|
+
const want = String(id || '').trim().toLowerCase();
|
|
54
|
+
if (want.length < 4) return null;
|
|
55
|
+
try {
|
|
56
|
+
const hits = fs.readdirSync(SESSIONS).filter((name) => name.endsWith('.json') && name.toLowerCase().startsWith(want));
|
|
57
|
+
return hits.length === 1 ? readSessionFile(path.join(SESSIONS, hits[0])) : null;
|
|
58
|
+
} catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readSessionFile(file) {
|
|
50
62
|
if (!file) return null;
|
|
51
63
|
try {
|
|
52
64
|
const session = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -57,13 +69,19 @@ export function loadSession(id) {
|
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
/** The same folder, however it was typed: on Windows `c:\app` and `C:\App` are one place. */
|
|
73
|
+
export const sameDir = (a, b) => {
|
|
74
|
+
const norm = (p) => { const r = path.resolve(String(p || '')); return process.platform === 'win32' ? r.toLowerCase() : r; };
|
|
75
|
+
return norm(a) === norm(b);
|
|
76
|
+
};
|
|
77
|
+
|
|
60
78
|
export function listSessions({ cwd = null } = {}) {
|
|
61
79
|
try {
|
|
62
80
|
return fs.readdirSync(SESSIONS)
|
|
63
81
|
.filter((name) => name.endsWith('.json'))
|
|
64
82
|
.map((name) => loadSession(name.slice(0, -5)))
|
|
65
83
|
.filter(Boolean)
|
|
66
|
-
.filter((session) => !cwd ||
|
|
84
|
+
.filter((session) => !cwd || sameDir(session.cwd, cwd))
|
|
67
85
|
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
68
86
|
} catch {
|
|
69
87
|
return [];
|
package/src/terminal.js
CHANGED
|
@@ -8,28 +8,57 @@
|
|
|
8
8
|
// instead of painting codes that render as nothing. Sixel capability is
|
|
9
9
|
// attribute 4 (";4" in the report).
|
|
10
10
|
//
|
|
11
|
+
// The same handshake asks for the BACKGROUND colour (OSC 11). "auto" used to read only
|
|
12
|
+
// COLORFGBG, which macOS Terminal, VS Code and Windows Terminal never set, so a light
|
|
13
|
+
// terminal got the dark theme: near-white input text on a white window. OSC 11 is sent
|
|
14
|
+
// FIRST and DA second: terminals answer in order and every terminal answers DA, so a DA
|
|
15
|
+
// reply with no colour before it means "does not support the query", and nothing waits
|
|
16
|
+
// for a reply that will never come (the approach Neovim and terminal-colorsaurus use).
|
|
17
|
+
//
|
|
11
18
|
// Done inline as a subprocess-style handshake: raw mode for the duration so
|
|
12
19
|
// the reply doesn't echo, restored on the way out. Cached per process;
|
|
13
20
|
// `probeDevice` does the work once.
|
|
14
21
|
|
|
15
22
|
import fs from 'node:fs';
|
|
16
23
|
|
|
17
|
-
const state = { done: false, vt: false, sixel: false };
|
|
24
|
+
const state = { done: false, vt: false, sixel: false, background: null };
|
|
18
25
|
let pending = null;
|
|
19
26
|
|
|
20
27
|
/** A device-attributes reply: CSI [? params c. */
|
|
21
28
|
const DA_REPLY = /\x1b\[[?0-9;]*c/;
|
|
22
29
|
/** The replies that mean "yes, images": xterm's CSI ? 6 2 ; ... c and kin. */
|
|
23
30
|
const SIXEL_ATTR = /;4[;c]/;
|
|
31
|
+
/** A background-colour reply: OSC 11 ; rgb:R/G/B (1-4 hex digits each), ended by BEL or ST. */
|
|
32
|
+
const BG_REPLY = /\x1b\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\x07|\x1b\\)/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 'light' or 'dark' from an OSC 11 reply, or null. By WCAG relative luminance, with the
|
|
36
|
+
* cut at 0.179 — the point where black text and white text contrast equally with it.
|
|
37
|
+
*/
|
|
38
|
+
export function backgroundFromReply(text) {
|
|
39
|
+
const m = BG_REPLY.exec(String(text || ''));
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const [r, g, b] = m.slice(1, 4).map((h) => parseInt(h, 16) / (16 ** h.length - 1));
|
|
42
|
+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
43
|
+
const luminance = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
44
|
+
return luminance > 0.179 ? 'light' : 'dark';
|
|
45
|
+
}
|
|
24
46
|
|
|
25
47
|
// 300ms was too tight. A terminal over SSH, one under a multiplexer, or one whose reply
|
|
26
48
|
// queues behind anything else on stdin misses it — and a missed reply used to be read as
|
|
27
49
|
// "this console cannot do escape sequences", which turned the whole UI to ASCII twins.
|
|
28
50
|
// Startup does not wait on this: the probe runs concurrently with the model lookup.
|
|
29
|
-
|
|
51
|
+
// `input`/`write` are the terminal's two directions; the defaults are the real ones, and
|
|
52
|
+
// tests pass a stand-in to hold the exact conversation a terminal has with the app.
|
|
53
|
+
export function probeDevice({
|
|
54
|
+
timeoutMs = 900,
|
|
55
|
+
input = process.stdin,
|
|
56
|
+
write = (text) => fs.writeSync(1, text),
|
|
57
|
+
tty = Boolean(process.stdout.isTTY && process.stdin.isTTY),
|
|
58
|
+
} = {}) {
|
|
30
59
|
if (state.done) return Promise.resolve({ vt: state.vt, sixel: state.sixel });
|
|
31
60
|
if (pending) return pending;
|
|
32
|
-
if (!
|
|
61
|
+
if (!tty) {
|
|
33
62
|
state.done = true;
|
|
34
63
|
return Promise.resolve({ vt: false, sixel: false });
|
|
35
64
|
}
|
|
@@ -38,18 +67,19 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
|
|
|
38
67
|
let finished = false;
|
|
39
68
|
let buffer = '';
|
|
40
69
|
let wasRaw = false;
|
|
41
|
-
try { wasRaw = !!
|
|
70
|
+
try { wasRaw = !!input.isRaw; } catch { /* not a TTY */ }
|
|
42
71
|
const finish = () => {
|
|
43
72
|
if (finished) return;
|
|
44
73
|
finished = true;
|
|
45
74
|
clearTimeout(timer);
|
|
46
|
-
try {
|
|
47
|
-
try {
|
|
48
|
-
|
|
75
|
+
try { input.setRawMode(wasRaw); } catch { /* already gone */ }
|
|
76
|
+
try { input.pause(); } catch { /* already gone */ }
|
|
77
|
+
input.removeListener('data', onData);
|
|
49
78
|
const vt = DA_REPLY.test(buffer);
|
|
50
79
|
state.done = true;
|
|
51
80
|
state.vt = vt;
|
|
52
81
|
state.sixel = vt && SIXEL_ATTR.test(buffer);
|
|
82
|
+
state.background = backgroundFromReply(buffer);
|
|
53
83
|
pending = null;
|
|
54
84
|
resolve({ vt: state.vt, sixel: state.sixel });
|
|
55
85
|
};
|
|
@@ -62,11 +92,11 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
|
|
|
62
92
|
const timer = setTimeout(finish, timeoutMs);
|
|
63
93
|
|
|
64
94
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
input.setRawMode(true);
|
|
96
|
+
input.resume();
|
|
97
|
+
input.on('data', onData);
|
|
68
98
|
// Ask on stdout. The reply comes back through stdin as if typed.
|
|
69
|
-
|
|
99
|
+
write('\x1b]11;?\x1b\\\x1b[c');
|
|
70
100
|
} catch {
|
|
71
101
|
finish();
|
|
72
102
|
}
|
|
@@ -74,6 +104,11 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
|
|
|
74
104
|
return pending;
|
|
75
105
|
}
|
|
76
106
|
|
|
107
|
+
/** 'light' | 'dark' as the terminal itself reported it, or null when it did not say. */
|
|
108
|
+
export function terminalBackground() {
|
|
109
|
+
return state.background;
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
/** Legacy wrapper: true when this terminal shows sixel images. */
|
|
78
113
|
export async function probeAsync({ timeoutMs = 400 } = {}) {
|
|
79
114
|
const { sixel } = await probeDevice({ timeoutMs });
|
package/src/tui/ansi.js
CHANGED
|
@@ -225,7 +225,9 @@ export function wrapRow(row, width, indent = '') {
|
|
|
225
225
|
for (const ch of chunk) {
|
|
226
226
|
const cw = cellWidth(ch.codePointAt(0));
|
|
227
227
|
if (used + pieceWidth + cw > width) {
|
|
228
|
-
|
|
228
|
+
// `cut` marks a word split mid-way, so a URL cut here can be rejoined when it is
|
|
229
|
+
// clicked (the renderer ignores the field).
|
|
230
|
+
current.push(piece ? { ...span, t: piece, cut: true } : { ...span, t: piece });
|
|
229
231
|
flush();
|
|
230
232
|
piece = '';
|
|
231
233
|
pieceWidth = 0;
|