jaitus 0.3.12 → 0.3.14
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/bin/jaitus.js +86 -73
- package/package.json +2 -2
- package/src/agent/loop.js +998 -980
- package/src/agent/tools.js +11 -5
- package/src/api.js +55 -21
- package/src/commands/update.js +75 -49
- package/src/errors.js +120 -106
- package/src/index.js +3152 -3142
- package/src/rewind.js +149 -148
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui/clipboard.js +172 -161
- package/src/tui-ink.js +2841 -2605
- package/src/tui.js +2560 -2559
package/src/tui/clipboard.js
CHANGED
|
@@ -1,161 +1,172 @@
|
|
|
1
|
-
// ─── Reading the system clipboard ───────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// The app takes the mouse, so a terminal's own right-click paste never happens — the
|
|
4
|
-
// click is reported to the app instead, and it used to be thrown away: "paste does not
|
|
5
|
-
// work". Ctrl+V is the terminal's in some terminals and the app's in others. So the app
|
|
6
|
-
// reads the clipboard itself for right-click and Ctrl+V, and for Alt+V (an image, which
|
|
7
|
-
// no terminal can type in).
|
|
8
|
-
//
|
|
9
|
-
// Asynchronous on purpose: PowerShell takes a few hundred milliseconds to start, and a
|
|
10
|
-
// synchronous read froze the whole interface for that long.
|
|
11
|
-
|
|
12
|
-
import { spawn } from 'node:child_process';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
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
|
-
const bytes = await run(
|
|
158
|
-
if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1
|
+
// ─── Reading the system clipboard ───────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The app takes the mouse, so a terminal's own right-click paste never happens — the
|
|
4
|
+
// click is reported to the app instead, and it used to be thrown away: "paste does not
|
|
5
|
+
// work". Ctrl+V is the terminal's in some terminals and the app's in others. So the app
|
|
6
|
+
// reads the clipboard itself for right-click and Ctrl+V, and for Alt+V (an image, which
|
|
7
|
+
// no terminal can type in).
|
|
8
|
+
//
|
|
9
|
+
// Asynchronous on purpose: PowerShell takes a few hundred milliseconds to start, and a
|
|
10
|
+
// synchronous read froze the whole interface for that long.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The bytes to hand a clipboard tool for `text`. Windows' clip.exe decodes what it is
|
|
16
|
+
* piped with the console's old OEM code page unless the bytes are UTF-16: fed UTF-8,
|
|
17
|
+
* every non-ASCII character came out as mojibake — "▌ ✗ │" pasted as "Γûî Γ£ù Γöé".
|
|
18
|
+
* UTF-16LE with no byte-order mark lands exactly (with one, the mark is pasted too).
|
|
19
|
+
* Every other tool takes UTF-8.
|
|
20
|
+
*/
|
|
21
|
+
export function clipboardBytes(cmd, text) {
|
|
22
|
+
return cmd === 'clip' ? Buffer.from(String(text), 'utf16le') : Buffer.from(String(text), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function capture(cmd, args, { timeout = 4000, binary = false } = {}) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let child;
|
|
28
|
+
try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); } catch { resolve(null); return; }
|
|
29
|
+
const chunks = [];
|
|
30
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, timeout);
|
|
31
|
+
timer.unref?.();
|
|
32
|
+
child.stdout.on('data', (d) => chunks.push(d));
|
|
33
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
34
|
+
child.on('close', (code) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
if (code !== 0) return resolve(null);
|
|
37
|
+
const buf = Buffer.concat(chunks);
|
|
38
|
+
resolve(binary ? buf : buf.toString('utf8'));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A stand-in clipboard for the terminal tests: when JAITUS_CLIPBOARD_DIR names a folder,
|
|
44
|
+
// text comes from text.txt and an image from image.png in it, and the system clipboard
|
|
45
|
+
// is never read. Tests drive the real app in a real terminal, and they must not read or
|
|
46
|
+
// overwrite whatever the person running them has copied.
|
|
47
|
+
async function standIn(kind) {
|
|
48
|
+
const dir = process.env.JAITUS_CLIPBOARD_DIR;
|
|
49
|
+
if (!dir) return undefined;
|
|
50
|
+
const fs = await import('node:fs');
|
|
51
|
+
const path = await import('node:path');
|
|
52
|
+
try {
|
|
53
|
+
if (kind === 'text') return fs.readFileSync(path.join(dir, 'text.txt'), 'utf8') || null;
|
|
54
|
+
const bytes = fs.readFileSync(path.join(dir, 'image.png'));
|
|
55
|
+
return bytes.length ? { bytes, mime: 'image/png', ext: '.png' } : null;
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Plain text on the clipboard, or null (empty, not text, or no clipboard tool). */
|
|
60
|
+
export async function readClipboardText({ platform = process.platform, run = capture } = {}) {
|
|
61
|
+
const fake = await standIn('text');
|
|
62
|
+
if (fake !== undefined) return fake;
|
|
63
|
+
let text = null;
|
|
64
|
+
if (platform === 'win32') {
|
|
65
|
+
// UTF-8 out, or anything outside the console code page (quotes, dashes, non-Latin
|
|
66
|
+
// text) arrives as '?'. -Raw keeps line breaks as they were copied.
|
|
67
|
+
text = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
68
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8; $t = Get-Clipboard -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }']);
|
|
69
|
+
} else if (platform === 'darwin') {
|
|
70
|
+
text = await run('pbpaste', []);
|
|
71
|
+
} else {
|
|
72
|
+
for (const [cmd, args] of [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
|
|
73
|
+
text = await run(cmd, args);
|
|
74
|
+
if (text != null) break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return text ? text : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The largest image the gateway path takes comfortably (see imageBlock in tools.js).
|
|
81
|
+
export const IMAGE_MAX_BYTES = 3_500_000;
|
|
82
|
+
// Vision models downscale anything larger than about this themselves; sending more
|
|
83
|
+
// pixels only costs tokens and upload time.
|
|
84
|
+
const MAX_SIDE = 2048;
|
|
85
|
+
|
|
86
|
+
// PowerShell: scale an image down to MAX_SIDE on its long side, then PNG if that fits
|
|
87
|
+
// the limit (text in screenshots stays crisp), JPEG otherwise. Writes base64 to stdout.
|
|
88
|
+
const PS_ENCODE = `
|
|
89
|
+
function Emit($img) {
|
|
90
|
+
$w = $img.Width; $h = $img.Height; $s = [Math]::Min(1.0, ${MAX_SIDE} / [Math]::Max($w, $h))
|
|
91
|
+
if ($s -lt 1.0) {
|
|
92
|
+
$b = New-Object Drawing.Bitmap ([int]($w * $s)), ([int]($h * $s))
|
|
93
|
+
$g = [Drawing.Graphics]::FromImage($b); $g.InterpolationMode = 'HighQualityBicubic'
|
|
94
|
+
$g.DrawImage($img, 0, 0, $b.Width, $b.Height); $g.Dispose(); $img = $b
|
|
95
|
+
}
|
|
96
|
+
$m = New-Object IO.MemoryStream; $img.Save($m, [Drawing.Imaging.ImageFormat]::Png)
|
|
97
|
+
if ($m.Length -gt ${IMAGE_MAX_BYTES}) {
|
|
98
|
+
$m = New-Object IO.MemoryStream
|
|
99
|
+
$enc = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
|
100
|
+
$p = New-Object Drawing.Imaging.EncoderParameters 1
|
|
101
|
+
$p.Param[0] = New-Object Drawing.Imaging.EncoderParameter ([Drawing.Imaging.Encoder]::Quality), 85L
|
|
102
|
+
$img.Save($m, $enc, $p); [Console]::Out.Write('jpeg:')
|
|
103
|
+
} else { [Console]::Out.Write('png:') }
|
|
104
|
+
[Console]::Out.Write([Convert]::ToBase64String($m.ToArray()))
|
|
105
|
+
}`;
|
|
106
|
+
|
|
107
|
+
const decodeEmitted = (out) => {
|
|
108
|
+
const m = /^(png|jpeg):([A-Za-z0-9+/=\s]+)$/.exec(String(out || '').trim());
|
|
109
|
+
return m ? { bytes: Buffer.from(m[2].replace(/\s/g, ''), 'base64'), mime: `image/${m[1]}`, ext: m[1] === 'png' ? '.png' : '.jpg' } : null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An image file ready to attach: itself when it is already within the limit, otherwise a
|
|
114
|
+
* smaller copy in the temp folder (null if it cannot be made small enough). Dragging a
|
|
115
|
+
* 4K screenshot onto the terminal is the common case — rejecting those would make
|
|
116
|
+
* attaching screenshots fail most of the time.
|
|
117
|
+
*/
|
|
118
|
+
export async function shrinkImageFile(file, { platform = process.platform, run = capture } = {}) {
|
|
119
|
+
const fs = await import('node:fs');
|
|
120
|
+
const os = await import('node:os');
|
|
121
|
+
const path = await import('node:path');
|
|
122
|
+
let size = 0;
|
|
123
|
+
try { size = fs.statSync(file).size; } catch { return null; }
|
|
124
|
+
if (size <= IMAGE_MAX_BYTES) return file;
|
|
125
|
+
const out = path.join(os.tmpdir(), `jaitus-img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
126
|
+
if (platform === 'win32') {
|
|
127
|
+
const lit = file.replace(/'/g, "''");
|
|
128
|
+
const r = decodeEmitted(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
129
|
+
`Add-Type -AssemblyName System.Drawing; ${PS_ENCODE}; $i = [Drawing.Image]::FromFile('${lit}'); Emit $i; $i.Dispose()`], { timeout: 20000 }));
|
|
130
|
+
if (!r) return null;
|
|
131
|
+
fs.writeFileSync(out + r.ext, r.bytes);
|
|
132
|
+
return r.bytes.length <= IMAGE_MAX_BYTES ? out + r.ext : null;
|
|
133
|
+
}
|
|
134
|
+
if (platform === 'darwin') {
|
|
135
|
+
const ok = await run('sips', ['-Z', String(MAX_SIDE), '-s', 'format', 'jpeg', '-s', 'formatOptions', '85', file, '--out', `${out}.jpg`], { timeout: 20000 });
|
|
136
|
+
return ok != null && fs.existsSync(`${out}.jpg`) && fs.statSync(`${out}.jpg`).size <= IMAGE_MAX_BYTES ? `${out}.jpg` : null;
|
|
137
|
+
}
|
|
138
|
+
for (const cmd of ['magick', 'convert']) {
|
|
139
|
+
const ok = await run(cmd, [file, '-resize', `${MAX_SIDE}x${MAX_SIDE}>`, '-quality', '85', `${out}.jpg`], { timeout: 20000 });
|
|
140
|
+
if (ok != null && fs.existsSync(`${out}.jpg`)) return fs.statSync(`${out}.jpg`).size <= IMAGE_MAX_BYTES ? `${out}.jpg` : null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** An image on the clipboard as { bytes, mime, ext }, already sized to attach, or null. */
|
|
146
|
+
export async function readClipboardImage({ platform = process.platform, run = capture } = {}) {
|
|
147
|
+
const fake = await standIn('image');
|
|
148
|
+
if (fake !== undefined) return fake;
|
|
149
|
+
if (platform === 'win32') {
|
|
150
|
+
return decodeEmitted(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
151
|
+
`Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ${PS_ENCODE}; $i = [Windows.Forms.Clipboard]::GetImage(); if (!$i) { exit 1 }; Emit $i`],
|
|
152
|
+
{ timeout: 12000 }));
|
|
153
|
+
}
|
|
154
|
+
// Elsewhere the raw PNG; the caller writes it out and shrinks it with shrinkImageFile.
|
|
155
|
+
if (platform === 'darwin') {
|
|
156
|
+
// pngpaste is not built in; osascript is, and can write the clipboard's PNG itself.
|
|
157
|
+
const bytes = await run('pngpaste', ['-'], { timeout: 8000, binary: true });
|
|
158
|
+
if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
|
|
159
|
+
const os = await import('node:os');
|
|
160
|
+
const fs = await import('node:fs');
|
|
161
|
+
const path = await import('node:path');
|
|
162
|
+
const out = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}.png`);
|
|
163
|
+
const ok = await run('osascript', ['-e', `set f to open for access POSIX file "${out}" with write permission`, '-e', 'write (the clipboard as «class PNGf») to f', '-e', 'close access f'], { timeout: 8000 });
|
|
164
|
+
if (ok != null && fs.existsSync(out) && fs.statSync(out).size > 0) return { bytes: fs.readFileSync(out), mime: 'image/png', ext: '.png' };
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
|
|
168
|
+
const bytes = await run(cmd, args, { timeout: 8000, binary: true });
|
|
169
|
+
if (bytes?.length) return { bytes, mime: 'image/png', ext: '.png' };
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|