@pmndrs/pine 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +180 -2
- package/bin/pine.js +27 -0
- package/docs/SKILL.md +189 -0
- package/docs/references/agents.md +368 -0
- package/docs/references/coding.md +126 -0
- package/docs/references/configuration.md +229 -0
- package/docs/references/events.md +263 -0
- package/docs/references/mcp.md +257 -0
- package/docs/references/operations.md +280 -0
- package/docs/references/overview.md +186 -0
- package/docs/references/security.md +489 -0
- package/docs/references/setup.md +181 -0
- package/docs/references/sources.md +768 -0
- package/docs/references/stack.md +105 -0
- package/docs/references/tui.md +348 -0
- package/examples/home/.env.example +5 -0
- package/examples/home/USER.md +4 -0
- package/examples/home/agents/main/agent.json +4 -0
- package/examples/home/agents/main/workspace/AGENTS.md +14 -0
- package/examples/home/agents/main/workspace/MEMORY.md +4 -0
- package/examples/home/agents/main/workspace/SOUL.md +4 -0
- package/examples/home/models.json +62 -0
- package/examples/home/pine.json +30 -0
- package/examples/home/skills/README.txt +7 -0
- package/examples/launchd/com.pine.daemon.plist +32 -0
- package/mcp/browser.d.ts +34 -0
- package/mcp/browser.js +228 -0
- package/mcp/commands.d.ts +20 -0
- package/mcp/commands.js +88 -0
- package/mcp/lib.d.ts +13 -0
- package/mcp/lib.js +50 -0
- package/mcp/web.d.ts +6 -0
- package/mcp/web.js +140 -0
- package/package.json +32 -4
- package/src/agent-admin.d.ts +24 -0
- package/src/agent-admin.js +55 -0
- package/src/agents.d.ts +343 -0
- package/src/agents.js +1587 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +526 -0
- package/src/config.d.ts +351 -0
- package/src/config.js +514 -0
- package/src/dev/claude-model.d.ts +28 -0
- package/src/dev/claude-model.js +174 -0
- package/src/env-file.d.ts +4 -0
- package/src/env-file.js +40 -0
- package/src/http.d.ts +46 -0
- package/src/http.js +407 -0
- package/src/limiter.d.ts +32 -0
- package/src/limiter.js +84 -0
- package/src/login.d.ts +21 -0
- package/src/login.js +126 -0
- package/src/manual.d.ts +61 -0
- package/src/manual.js +425 -0
- package/src/mcp-config.d.ts +40 -0
- package/src/mcp-config.js +132 -0
- package/src/net.d.ts +36 -0
- package/src/net.js +122 -0
- package/src/pine.d.ts +32 -0
- package/src/pine.js +142 -0
- package/src/router.d.ts +56 -0
- package/src/router.js +164 -0
- package/src/sandbox-fs.d.ts +27 -0
- package/src/sandbox-fs.js +172 -0
- package/src/security.d.ts +121 -0
- package/src/security.js +363 -0
- package/src/skills.d.ts +67 -0
- package/src/skills.js +163 -0
- package/src/source-types.d.ts +61 -0
- package/src/source-types.js +1 -0
- package/src/sources/command.d.ts +4 -0
- package/src/sources/command.js +77 -0
- package/src/sources/folder.d.ts +6 -0
- package/src/sources/folder.js +116 -0
- package/src/sources/ftp.d.ts +4 -0
- package/src/sources/ftp.js +307 -0
- package/src/sources/http.d.ts +4 -0
- package/src/sources/http.js +201 -0
- package/src/sources/mqtt.d.ts +18 -0
- package/src/sources/mqtt.js +97 -0
- package/src/sources/telegram.d.ts +22 -0
- package/src/sources/telegram.js +269 -0
- package/src/sources/webhook.d.ts +4 -0
- package/src/sources/webhook.js +52 -0
- package/src/sources.d.ts +339 -0
- package/src/sources.js +955 -0
- package/src/store.d.ts +89 -0
- package/src/store.js +251 -0
- package/src/timers.d.ts +83 -0
- package/src/timers.js +318 -0
- package/src/tools.d.ts +6 -0
- package/src/tools.js +777 -0
- package/src/tui/app.d.ts +209 -0
- package/src/tui/app.js +2223 -0
- package/src/tui/client.d.ts +178 -0
- package/src/tui/client.js +116 -0
- package/src/tui/code-pane.d.ts +51 -0
- package/src/tui/code-pane.js +398 -0
- package/src/tui/highlight.d.ts +8 -0
- package/src/tui/highlight.js +86 -0
- package/src/tui/picture.d.ts +141 -0
- package/src/tui/picture.js +571 -0
- package/src/tui/run.d.ts +8 -0
- package/src/tui/run.js +97 -0
- package/src/tui/theme.d.ts +32 -0
- package/src/tui/theme.js +128 -0
- package/src/types.d.ts +124 -0
- package/src/types.js +1 -0
- package/src/update.d.ts +25 -0
- package/src/update.js +110 -0
- package/src/util.d.ts +32 -0
- package/src/util.js +116 -0
- package/src/watching.d.ts +20 -0
- package/src/watching.js +68 -0
- package/src/workspace-view.d.ts +38 -0
- package/src/workspace-view.js +160 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
import { allocateImageId, getCapabilities, getCellDimensions, getImageDimensions, Image, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { c } from "./theme.js";
|
|
8
|
+
const SIPS = "/usr/bin/sips";
|
|
9
|
+
const hasSips = process.platform === "darwin" && fs.existsSync(SIPS);
|
|
10
|
+
const TMP = fs.realpathSync(os.tmpdir());
|
|
11
|
+
/**
|
|
12
|
+
* Start a converter (sips, ffmpeg) on bytes that can come from anywhere: it gets no network and
|
|
13
|
+
* may write only in the temp folder (sips keeps scratch files of its own there).
|
|
14
|
+
*/
|
|
15
|
+
function confined(command, args, stdio, cwd = TMP) {
|
|
16
|
+
if (process.platform !== "darwin")
|
|
17
|
+
return spawn(command, args, { cwd, stdio: ["ignore", stdio, "ignore"] });
|
|
18
|
+
const profile = `(version 1)(allow default)(deny network*)(deny file-write*)(allow file-write* (subpath "${TMP.replace(/["\\]/g, "\\$&")}") (literal "/dev/null"))`;
|
|
19
|
+
return spawn("/usr/bin/sandbox-exec", ["-p", profile, command, ...args], { cwd, stdio: ["ignore", stdio, "ignore"] });
|
|
20
|
+
}
|
|
21
|
+
function sips(dir, args) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const child = confined(SIPS, args, "ignore", dir);
|
|
24
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 15_000);
|
|
25
|
+
child.on("error", () => (clearTimeout(timer), resolve(false)));
|
|
26
|
+
child.on("close", (code) => (clearTimeout(timer), resolve(code === 0)));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Read an uncompressed 24 or 32 bit BMP, the simplest format sips writes. Transparent parts are shown on dark grey. */
|
|
30
|
+
export function parseBmp(b) {
|
|
31
|
+
if (b.length < 54 || b.toString("latin1", 0, 2) !== "BM")
|
|
32
|
+
return undefined;
|
|
33
|
+
const offset = b.readUInt32LE(10);
|
|
34
|
+
const width = b.readInt32LE(18);
|
|
35
|
+
const rawHeight = b.readInt32LE(22);
|
|
36
|
+
const height = Math.abs(rawHeight);
|
|
37
|
+
const bpp = b.readUInt16LE(28);
|
|
38
|
+
const compression = b.readUInt32LE(30);
|
|
39
|
+
if (width <= 0 || height <= 0 || width > 4096 || height > 4096 || (bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3))
|
|
40
|
+
return undefined;
|
|
41
|
+
const bytes = bpp / 8;
|
|
42
|
+
const stride = Math.ceil((width * bytes) / 4) * 4;
|
|
43
|
+
if (b.length < offset + stride * height)
|
|
44
|
+
return undefined;
|
|
45
|
+
// Where each channel sits in a pixel. With "bitfields" the header says; otherwise it is blue, green, red(, alpha).
|
|
46
|
+
const at = (mask, fallback) => (mask === 0xff ? 0 : mask === 0xff00 ? 1 : mask === 0xff0000 ? 2 : mask === 0xff000000 ? 3 : fallback);
|
|
47
|
+
const fields = compression === 3 && b.length >= 70;
|
|
48
|
+
const r = fields ? at(b.readUInt32LE(54), 2) : 2;
|
|
49
|
+
const g = fields ? at(b.readUInt32LE(58), 1) : 1;
|
|
50
|
+
const bl = fields ? at(b.readUInt32LE(62), 0) : 0;
|
|
51
|
+
const a = bpp === 32 ? (fields ? at(b.readUInt32LE(66), 3) : 3) : -1;
|
|
52
|
+
const rgb = new Uint8Array(width * height * 3);
|
|
53
|
+
for (let y = 0; y < height; y++) {
|
|
54
|
+
const row = offset + (rawHeight < 0 ? y : height - 1 - y) * stride;
|
|
55
|
+
for (let x = 0; x < width; x++) {
|
|
56
|
+
const p = row + x * bytes;
|
|
57
|
+
const alpha = a >= 0 ? b[p + a] / 255 : 1;
|
|
58
|
+
const o = (y * width + x) * 3;
|
|
59
|
+
rgb[o] = Math.round(b[p + r] * alpha + 30 * (1 - alpha));
|
|
60
|
+
rgb[o + 1] = Math.round(b[p + g] * alpha + 30 * (1 - alpha));
|
|
61
|
+
rgb[o + 2] = Math.round(b[p + bl] * alpha + 30 * (1 - alpha));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { width, height, rgb };
|
|
65
|
+
}
|
|
66
|
+
let queue = Promise.resolve();
|
|
67
|
+
/**
|
|
68
|
+
* Make a picture ready to draw. Returns what could be made: without sips (not macOS) a png still
|
|
69
|
+
* shows on a Kitty-protocol terminal, and anything else is shown as a line of text.
|
|
70
|
+
*/
|
|
71
|
+
export function prepare(bytes, mime, name) {
|
|
72
|
+
// One at a time: a page full of pictures should not start a dozen converters at once.
|
|
73
|
+
const job = queue.then(() => convert(bytes, mime, name));
|
|
74
|
+
queue = job.catch(() => { });
|
|
75
|
+
return job;
|
|
76
|
+
}
|
|
77
|
+
async function convert(bytes, mime, name) {
|
|
78
|
+
const dims = getImageDimensions(bytes.subarray(0, 512 * 1024).toString("base64"), mime) ?? { widthPx: 0, heightPx: 0 };
|
|
79
|
+
const out = { name, widthPx: dims.widthPx, heightPx: dims.heightPx };
|
|
80
|
+
if (!hasSips) {
|
|
81
|
+
if (mime === "image/png")
|
|
82
|
+
out.png = bytes.toString("base64");
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
const dir = fs.mkdtempSync(path.join(TMP, "pine-pic-"));
|
|
86
|
+
try {
|
|
87
|
+
const source = path.join(dir, "in");
|
|
88
|
+
fs.writeFileSync(source, bytes, { mode: 0o600 });
|
|
89
|
+
const kitty = getCapabilities().images === "kitty";
|
|
90
|
+
// sips scales to the size it is given, also upwards; a small picture stays as small as it is.
|
|
91
|
+
const longest = Math.max(dims.widthPx, dims.heightPx) || 1200;
|
|
92
|
+
const fit = (limit) => String(Math.min(limit, longest));
|
|
93
|
+
if (kitty && (await sips(dir, ["-s", "format", "png", "-Z", fit(1200), source, "--out", path.join(dir, "out.png")])))
|
|
94
|
+
out.png = fs.readFileSync(path.join(dir, "out.png")).toString("base64");
|
|
95
|
+
// The small copy is made in both cases: it is cheap, and it is what shows if the real picture cannot.
|
|
96
|
+
if (await sips(dir, ["-s", "format", "bmp", "-Z", fit(240), source, "--out", path.join(dir, "out.bmp")]))
|
|
97
|
+
out.pixels = parseBmp(fs.readFileSync(path.join(dir, "out.bmp")));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// shown as text
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Average colour of a rectangle of the source, for scaling down without speckle. */
|
|
108
|
+
function sample(p, x0, y0, x1, y1) {
|
|
109
|
+
const ax = Math.max(0, Math.min(p.width - 1, Math.floor(x0)));
|
|
110
|
+
const bx = Math.max(ax + 1, Math.min(p.width, Math.ceil(x1)));
|
|
111
|
+
const ay = Math.max(0, Math.min(p.height - 1, Math.floor(y0)));
|
|
112
|
+
const by = Math.max(ay + 1, Math.min(p.height, Math.ceil(y1)));
|
|
113
|
+
let r = 0;
|
|
114
|
+
let g = 0;
|
|
115
|
+
let b = 0;
|
|
116
|
+
for (let y = ay; y < by; y++) {
|
|
117
|
+
for (let x = ax; x < bx; x++) {
|
|
118
|
+
const o = (y * p.width + x) * 3;
|
|
119
|
+
r += p.rgb[o];
|
|
120
|
+
g += p.rgb[o + 1];
|
|
121
|
+
b += p.rgb[o + 2];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const n = (bx - ax) * (by - ay);
|
|
125
|
+
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
|
|
126
|
+
}
|
|
127
|
+
const cube = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.floor((v - 35) / 40)));
|
|
128
|
+
/** Draw a picture with half blocks, at most `maxCols` cells wide and `maxRows` lines high. */
|
|
129
|
+
export function halfBlocks(p, maxCols, maxRows, trueColor = true) {
|
|
130
|
+
// A character cell is about twice as high as wide, so half a cell is about a square pixel.
|
|
131
|
+
let cols = Math.max(1, Math.min(maxCols, p.width));
|
|
132
|
+
let rows = Math.max(1, Math.ceil((cols * p.height) / p.width / 2));
|
|
133
|
+
if (rows > maxRows) {
|
|
134
|
+
rows = Math.max(1, maxRows);
|
|
135
|
+
cols = Math.max(1, Math.min(maxCols, Math.round((rows * 2 * p.width) / p.height)));
|
|
136
|
+
}
|
|
137
|
+
const sx = p.width / cols;
|
|
138
|
+
const sy = p.height / (rows * 2);
|
|
139
|
+
const paint = ([r, g, b], back) => (trueColor ? `\x1b[${back ? 48 : 38};2;${r};${g};${b}m` : `\x1b[${back ? 48 : 38};5;${16 + 36 * cube(r) + 6 * cube(g) + cube(b)}m`);
|
|
140
|
+
const lines = [];
|
|
141
|
+
for (let y = 0; y < rows; y++) {
|
|
142
|
+
let line = " ";
|
|
143
|
+
for (let x = 0; x < cols; x++)
|
|
144
|
+
line += `${paint(sample(p, x * sx, y * 2 * sy, (x + 1) * sx, (y * 2 + 1) * sy), false)}${paint(sample(p, x * sx, (y * 2 + 1) * sy, (x + 1) * sx, (y * 2 + 2) * sy), true)}▀`;
|
|
145
|
+
lines.push(`${line}\x1b[0m`);
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
const drawn = new Map();
|
|
150
|
+
function drawsAsText(id, owner) {
|
|
151
|
+
if (id === undefined || drawn.get(id)?.deref() === owner)
|
|
152
|
+
return;
|
|
153
|
+
if (drawn.size > 400)
|
|
154
|
+
for (const [key, ref] of drawn)
|
|
155
|
+
if (!ref.deref())
|
|
156
|
+
drawn.delete(key);
|
|
157
|
+
drawn.set(id, new WeakRef(owner));
|
|
158
|
+
}
|
|
159
|
+
/** Half blocks in the room of a real picture: at most `cols` cells wide, exactly `rows` lines. */
|
|
160
|
+
function inPlaceOf(pixels, cols, rows) {
|
|
161
|
+
const lines = pixels && rows > 0 ? halfBlocks(pixels, Math.max(1, cols - 1), rows, getCapabilities().trueColor) : [];
|
|
162
|
+
while (lines.length < rows)
|
|
163
|
+
lines.push("");
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The lines of a screen, with every real picture that `covered` says something lies over drawn as
|
|
168
|
+
* text instead. `covered` gets the picture's place: its first line and column, its width and height.
|
|
169
|
+
* Returns the same array when no picture is covered.
|
|
170
|
+
*/
|
|
171
|
+
export function picturesAsText(lines, covered) {
|
|
172
|
+
let out = lines;
|
|
173
|
+
for (let row = 0; row < lines.length; row++) {
|
|
174
|
+
if (!lines[row].includes("\x1b_G"))
|
|
175
|
+
continue;
|
|
176
|
+
const controls = /\x1b_G([^;]*);/.exec(lines[row])?.[1];
|
|
177
|
+
if (controls === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
const value = (key) => {
|
|
180
|
+
const m = new RegExp(`(?:^|,)${key}=(\\d+)`).exec(controls);
|
|
181
|
+
return m ? Number(m[1]) : undefined;
|
|
182
|
+
};
|
|
183
|
+
const cols = value("c") ?? 1;
|
|
184
|
+
const rows = value("r") ?? 1;
|
|
185
|
+
if (!covered(row, 0, cols, rows))
|
|
186
|
+
continue;
|
|
187
|
+
const whole = drawn.get(value("i") ?? 0)?.deref()?.blocks(value("i") ?? 0, cols) ?? [];
|
|
188
|
+
// A picture that the top of the view cuts off shows its lower lines, any other its upper ones.
|
|
189
|
+
const text = (value("y") ?? 0) > 0 ? whole.slice(-rows) : whole.slice(0, rows);
|
|
190
|
+
if (out === lines)
|
|
191
|
+
out = [...lines];
|
|
192
|
+
for (let i = 0; i < rows && row + i < out.length; i++)
|
|
193
|
+
out[row + i] = text[i] ?? "";
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** One picture in the conversation: a thumbnail that a click enlarges and a second click shrinks again. */
|
|
198
|
+
export class Picture {
|
|
199
|
+
p;
|
|
200
|
+
large = false;
|
|
201
|
+
images = new Map();
|
|
202
|
+
cache;
|
|
203
|
+
constructor(prepared) {
|
|
204
|
+
this.p = prepared;
|
|
205
|
+
}
|
|
206
|
+
invalidate() {
|
|
207
|
+
this.cache = undefined;
|
|
208
|
+
for (const image of this.images.values())
|
|
209
|
+
image.invalidate();
|
|
210
|
+
}
|
|
211
|
+
handleMouse(e) {
|
|
212
|
+
if (e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
|
|
213
|
+
return undefined;
|
|
214
|
+
if (e.type === "click") {
|
|
215
|
+
this.large = !this.large;
|
|
216
|
+
this.invalidate();
|
|
217
|
+
}
|
|
218
|
+
return { handled: true, render: true };
|
|
219
|
+
}
|
|
220
|
+
/** The drawing itself, without a caption: the real picture where the terminal can show one, half blocks otherwise, nothing if neither works. */
|
|
221
|
+
body(width, large = this.large) {
|
|
222
|
+
const caps = getCapabilities();
|
|
223
|
+
const maxCols = Math.max(4, Math.min(width - 2, large ? 140 : 44));
|
|
224
|
+
const maxRows = large ? 48 : 14;
|
|
225
|
+
if (caps.images === "kitty" && this.p.png) {
|
|
226
|
+
let image = this.images.get(large);
|
|
227
|
+
if (!image) {
|
|
228
|
+
image = new Image(this.p.png, "image/png", { fallbackColor: c.gray }, { maxWidthCells: maxCols, maxHeightCells: maxRows, filename: this.p.name });
|
|
229
|
+
this.images.set(large, image);
|
|
230
|
+
}
|
|
231
|
+
const lines = image.render(width);
|
|
232
|
+
drawsAsText(image.getImageId(), this);
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
return this.p.pixels ? halfBlocks(this.p.pixels, maxCols, maxRows, caps.trueColor) : [];
|
|
236
|
+
}
|
|
237
|
+
blocks(id, cols) {
|
|
238
|
+
const image = [...this.images.values()].find((i) => i.getImageId() === id);
|
|
239
|
+
return inPlaceOf(this.p.pixels, cols, image?.cachedLines?.length ?? 0);
|
|
240
|
+
}
|
|
241
|
+
render(width) {
|
|
242
|
+
const caps = getCapabilities();
|
|
243
|
+
const key = `${width}:${this.large}:${caps.images}`;
|
|
244
|
+
if (this.cache?.key === key)
|
|
245
|
+
return this.cache.lines;
|
|
246
|
+
const size = this.p.widthPx ? ` · ${this.p.widthPx}×${this.p.heightPx}` : "";
|
|
247
|
+
const body = this.body(width);
|
|
248
|
+
const caption = body.length ? `${this.p.name}${size} · click to ${this.large ? "shrink" : "enlarge"}` : `[picture: ${this.p.name}${size}; it cannot be drawn in this terminal]`;
|
|
249
|
+
const lines = [...body, truncateToWidth(c.gray(` ${caption}`), width)];
|
|
250
|
+
this.cache = { key, lines };
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** A place in the conversation that is filled in later, so a picture lands where it belongs however long it takes to load. */
|
|
255
|
+
export class Slot {
|
|
256
|
+
inner;
|
|
257
|
+
invalidate() {
|
|
258
|
+
this.inner?.invalidate();
|
|
259
|
+
}
|
|
260
|
+
handleMouse(e) {
|
|
261
|
+
return this.inner?.handleMouse?.(e);
|
|
262
|
+
}
|
|
263
|
+
render(width) {
|
|
264
|
+
return this.inner ? this.inner.render(width) : [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// ---- video ---------------------------------------------------------------------------------------
|
|
268
|
+
// A video shows its first moment as a picture. A click plays it in place, drawn with half blocks
|
|
269
|
+
// (which every terminal can do, frame after frame) and without sound; "open in player" hands the
|
|
270
|
+
// file to the system's player for the real thing. Decoding is ffmpeg's job; without ffmpeg there is
|
|
271
|
+
// a still from Quick Look and the player.
|
|
272
|
+
const findTool = (name) => [...(process.env.PATH ?? "").split(":"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"].map((d) => path.join(d, name)).find((f) => d_exists(f));
|
|
273
|
+
function d_exists(f) {
|
|
274
|
+
try {
|
|
275
|
+
return fs.statSync(f).isFile();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const FFMPEG = findTool("ffmpeg");
|
|
282
|
+
const FFPROBE = findTool("ffprobe");
|
|
283
|
+
/** Whether videos can be played in the conversation (ffmpeg is installed). */
|
|
284
|
+
export const canPlayVideo = Boolean(FFMPEG);
|
|
285
|
+
const finished = (child, ms) => new Promise((resolve) => {
|
|
286
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), ms);
|
|
287
|
+
child.on("error", () => (clearTimeout(timer), resolve(false)));
|
|
288
|
+
child.on("close", (code) => (clearTimeout(timer), resolve(code === 0)));
|
|
289
|
+
});
|
|
290
|
+
/** Get a video file ready to show: a still from its start, and how long it runs. */
|
|
291
|
+
export async function prepareVideo(file, name) {
|
|
292
|
+
const dir = fs.mkdtempSync(path.join(TMP, "pine-pic-"));
|
|
293
|
+
let poster = { name, widthPx: 0, heightPx: 0 };
|
|
294
|
+
let seconds;
|
|
295
|
+
try {
|
|
296
|
+
const still = path.join(dir, "poster.png");
|
|
297
|
+
if (FFMPEG)
|
|
298
|
+
await finished(confined(FFMPEG, ["-loglevel", "error", "-y", "-i", file, "-frames:v", "1", "-vf", "scale='min(1200,iw)':-2", still], "ignore"), 20_000);
|
|
299
|
+
if (!d_exists(still) && process.platform === "darwin") {
|
|
300
|
+
// Quick Look writes "<file name>.png" into the folder it is given.
|
|
301
|
+
await finished(confined("/usr/bin/qlmanage", ["-t", "-s", "1200", "-o", dir, file], "ignore"), 20_000);
|
|
302
|
+
const made = fs.readdirSync(dir).find((f) => f.endsWith(".png"));
|
|
303
|
+
if (made)
|
|
304
|
+
fs.renameSync(path.join(dir, made), still);
|
|
305
|
+
}
|
|
306
|
+
if (d_exists(still))
|
|
307
|
+
poster = await prepare(fs.readFileSync(still), "image/png", name);
|
|
308
|
+
if (FFPROBE) {
|
|
309
|
+
const probe = confined(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], "pipe");
|
|
310
|
+
let out = "";
|
|
311
|
+
probe.stdout?.on("data", (d) => (out += d));
|
|
312
|
+
if (await finished(probe, 10_000))
|
|
313
|
+
seconds = Number.parseFloat(out) || undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// shown without a still
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
return { file, name, seconds, poster };
|
|
323
|
+
}
|
|
324
|
+
/** A png from raw pixels (3 bytes each, rows from the top): what the Kitty graphics protocol takes. Fast, lightly compressed. */
|
|
325
|
+
export function pngFromRgb(rgb, width, height) {
|
|
326
|
+
const stride = width * 3;
|
|
327
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
328
|
+
for (let y = 0; y < height; y++)
|
|
329
|
+
raw.set(rgb.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
330
|
+
const chunk = (type, data) => {
|
|
331
|
+
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
332
|
+
const out = Buffer.alloc(body.length + 8);
|
|
333
|
+
out.writeUInt32BE(data.length, 0);
|
|
334
|
+
body.copy(out, 4);
|
|
335
|
+
out.writeUInt32BE(zlib.crc32(body) >>> 0, body.length + 4);
|
|
336
|
+
return out;
|
|
337
|
+
};
|
|
338
|
+
const head = Buffer.alloc(13);
|
|
339
|
+
head.writeUInt32BE(width, 0);
|
|
340
|
+
head.writeUInt32BE(height, 4);
|
|
341
|
+
head.set([8, 2, 0, 0, 0], 8);
|
|
342
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", head), chunk("IDAT", zlib.deflateSync(raw, { level: 1 })), chunk("IEND", Buffer.alloc(0))]);
|
|
343
|
+
}
|
|
344
|
+
const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
|
345
|
+
const FPS = 10;
|
|
346
|
+
let playing;
|
|
347
|
+
/**
|
|
348
|
+
* One video in the conversation. It plays at the size it is shown in. The picture itself is the
|
|
349
|
+
* size switch (click to enlarge, click to shrink), the first word of the caption is play and pause.
|
|
350
|
+
*
|
|
351
|
+
* Frames are drawn the way pictures are: as real images where the terminal has the Kitty graphics
|
|
352
|
+
* protocol (each frame replaces the last under one image id), as half blocks everywhere else.
|
|
353
|
+
*/
|
|
354
|
+
export class Video {
|
|
355
|
+
v;
|
|
356
|
+
still;
|
|
357
|
+
redraw;
|
|
358
|
+
open;
|
|
359
|
+
child;
|
|
360
|
+
ticker;
|
|
361
|
+
frame;
|
|
362
|
+
/** The current frame as a real image, on terminals that can show one. */
|
|
363
|
+
shown;
|
|
364
|
+
imageId = allocateImageId();
|
|
365
|
+
paused = false;
|
|
366
|
+
large = false;
|
|
367
|
+
/** Seconds of the video shown so far. */
|
|
368
|
+
position = 0;
|
|
369
|
+
width = 80;
|
|
370
|
+
captionRow = -1;
|
|
371
|
+
/** The lines the frame takes on screen. */
|
|
372
|
+
shownRows = 0;
|
|
373
|
+
playTo = -1;
|
|
374
|
+
openFrom = -1;
|
|
375
|
+
constructor(video, redraw, open) {
|
|
376
|
+
this.v = video;
|
|
377
|
+
this.still = new Picture(video.poster);
|
|
378
|
+
this.redraw = redraw;
|
|
379
|
+
this.open = open;
|
|
380
|
+
}
|
|
381
|
+
invalidate() {
|
|
382
|
+
this.still.invalidate();
|
|
383
|
+
}
|
|
384
|
+
get state() {
|
|
385
|
+
return !this.child ? "idle" : this.paused ? "paused" : "playing";
|
|
386
|
+
}
|
|
387
|
+
/** The cells a frame fills: the same room the still takes at the current size, in the video's own shape. */
|
|
388
|
+
size() {
|
|
389
|
+
const maxCols = Math.max(8, Math.min(this.width - 2, this.large ? 140 : 44));
|
|
390
|
+
const maxRows = this.large ? 48 : 14;
|
|
391
|
+
const shape = this.v.poster.widthPx && this.v.poster.heightPx ? this.v.poster.heightPx / this.v.poster.widthPx : 9 / 16;
|
|
392
|
+
const rows = Math.max(1, Math.ceil((maxCols * shape) / 2));
|
|
393
|
+
return rows <= maxRows ? { cols: maxCols, rows } : { cols: Math.max(8, Math.min(maxCols, Math.round((maxRows * 2) / shape))), rows: maxRows };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The pixels to decode a frame at. For half blocks: one pixel per half cell. For real images: as
|
|
397
|
+
* many pixels as those cells have on screen, up to a width that a terminal takes ten times a second.
|
|
398
|
+
*/
|
|
399
|
+
pixels(real) {
|
|
400
|
+
const { cols, rows } = this.size();
|
|
401
|
+
if (!real)
|
|
402
|
+
return { cols, rows, width: cols, height: rows * 2 };
|
|
403
|
+
const cell = getCellDimensions();
|
|
404
|
+
const shape = this.v.poster.widthPx && this.v.poster.heightPx ? this.v.poster.heightPx / this.v.poster.widthPx : 9 / 16;
|
|
405
|
+
const even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
406
|
+
const width = even(Math.min(cols * cell.widthPx, this.large ? 720 : 480, this.v.poster.widthPx || 720));
|
|
407
|
+
return { cols, rows, width, height: even(width * shape) };
|
|
408
|
+
}
|
|
409
|
+
/** Play, or go on after a pause. At the end it starts over. */
|
|
410
|
+
play() {
|
|
411
|
+
if (!FFMPEG)
|
|
412
|
+
return;
|
|
413
|
+
if (this.child) {
|
|
414
|
+
this.paused = false;
|
|
415
|
+
return this.redraw();
|
|
416
|
+
}
|
|
417
|
+
if (playing && playing !== this)
|
|
418
|
+
playing.stop();
|
|
419
|
+
playing = this;
|
|
420
|
+
this.start(0, false);
|
|
421
|
+
}
|
|
422
|
+
pause() {
|
|
423
|
+
if (!this.child || this.paused)
|
|
424
|
+
return;
|
|
425
|
+
this.paused = true;
|
|
426
|
+
this.redraw();
|
|
427
|
+
}
|
|
428
|
+
/** Decode from `from` seconds on, at the current size. `hold`: show one frame and stay paused there. */
|
|
429
|
+
start(from, hold) {
|
|
430
|
+
this.child?.kill("SIGKILL");
|
|
431
|
+
clearInterval(this.ticker);
|
|
432
|
+
const real = getCapabilities().images === "kitty";
|
|
433
|
+
const { cols, rows, width, height } = this.pixels(real);
|
|
434
|
+
const size = width * height * 3;
|
|
435
|
+
// ffmpeg decodes as fast as it can; the pace is kept here. A few frames are held ready, and
|
|
436
|
+
// ffmpeg waits (its output pipe fills up) while there are enough, which is also what a pause is.
|
|
437
|
+
const child = confined(FFMPEG, ["-loglevel", "error", ...(from > 0 ? ["-ss", from.toFixed(2)] : []), "-i", this.v.file, "-an", "-vf", `fps=${FPS},scale=${width}:${height}`, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], "pipe");
|
|
438
|
+
this.child = child;
|
|
439
|
+
this.position = from;
|
|
440
|
+
this.paused = hold;
|
|
441
|
+
let wanted = hold ? 1 : 0;
|
|
442
|
+
const ready = [];
|
|
443
|
+
let pending = Buffer.alloc(0);
|
|
444
|
+
let ended = false;
|
|
445
|
+
child.stdout?.on("data", (d) => {
|
|
446
|
+
pending = pending.length ? Buffer.concat([pending, d]) : d;
|
|
447
|
+
while (pending.length >= size) {
|
|
448
|
+
ready.push(new Uint8Array(pending.subarray(0, size)));
|
|
449
|
+
pending = pending.subarray(size);
|
|
450
|
+
}
|
|
451
|
+
if (ready.length > 8)
|
|
452
|
+
child.stdout?.pause();
|
|
453
|
+
});
|
|
454
|
+
child.on("error", () => (ended = true));
|
|
455
|
+
child.on("close", () => (ended = true));
|
|
456
|
+
const ticker = setInterval(() => {
|
|
457
|
+
if (this.child !== child)
|
|
458
|
+
return clearInterval(ticker);
|
|
459
|
+
if (this.paused && !wanted)
|
|
460
|
+
return;
|
|
461
|
+
const rgb = ready.shift();
|
|
462
|
+
if (rgb) {
|
|
463
|
+
this.frame = { width, height, rgb };
|
|
464
|
+
// The same image id every time: the terminal replaces the picture in place.
|
|
465
|
+
this.shown = real ? new Image(pngFromRgb(rgb, width, height).toString("base64"), "image/png", { fallbackColor: c.gray }, { maxWidthCells: cols, maxHeightCells: rows, imageId: this.imageId }, { widthPx: width, heightPx: height }) : undefined;
|
|
466
|
+
if (wanted)
|
|
467
|
+
wanted--;
|
|
468
|
+
else
|
|
469
|
+
this.position += 1 / FPS;
|
|
470
|
+
this.redraw();
|
|
471
|
+
}
|
|
472
|
+
else if (ended)
|
|
473
|
+
this.stop();
|
|
474
|
+
if (ready.length < 4)
|
|
475
|
+
child.stdout?.resume();
|
|
476
|
+
}, 1000 / FPS);
|
|
477
|
+
ticker.unref?.();
|
|
478
|
+
this.ticker = ticker;
|
|
479
|
+
this.redraw();
|
|
480
|
+
}
|
|
481
|
+
/** Back to the still. */
|
|
482
|
+
stop() {
|
|
483
|
+
const child = this.child;
|
|
484
|
+
clearInterval(this.ticker);
|
|
485
|
+
this.child = undefined;
|
|
486
|
+
this.frame = undefined;
|
|
487
|
+
this.shown = undefined;
|
|
488
|
+
this.paused = false;
|
|
489
|
+
this.position = 0;
|
|
490
|
+
if (playing === this)
|
|
491
|
+
playing = undefined;
|
|
492
|
+
child?.kill("SIGKILL");
|
|
493
|
+
this.redraw();
|
|
494
|
+
}
|
|
495
|
+
resize() {
|
|
496
|
+
this.large = !this.large;
|
|
497
|
+
this.still.invalidate();
|
|
498
|
+
// A video that is running or paused carries on where it is, at the new size.
|
|
499
|
+
if (this.child)
|
|
500
|
+
this.start(this.position, this.paused);
|
|
501
|
+
else
|
|
502
|
+
this.redraw();
|
|
503
|
+
}
|
|
504
|
+
handleMouse(e) {
|
|
505
|
+
if (e.button !== "left" || (e.type !== "press" && e.type !== "click" && e.type !== "release"))
|
|
506
|
+
return undefined;
|
|
507
|
+
if (e.type === "click") {
|
|
508
|
+
if (e.y !== this.captionRow)
|
|
509
|
+
this.resize();
|
|
510
|
+
else if (this.openFrom >= 0 && e.x >= this.openFrom)
|
|
511
|
+
this.open(this.v.file);
|
|
512
|
+
else if (e.x <= this.playTo)
|
|
513
|
+
this.state === "playing" ? this.pause() : this.play();
|
|
514
|
+
}
|
|
515
|
+
return { handled: true, render: true };
|
|
516
|
+
}
|
|
517
|
+
blocks(_id, cols) {
|
|
518
|
+
return inPlaceOf(this.frame, cols, this.shownRows);
|
|
519
|
+
}
|
|
520
|
+
render(width) {
|
|
521
|
+
this.width = width;
|
|
522
|
+
if (this.shown)
|
|
523
|
+
drawsAsText(this.imageId, this);
|
|
524
|
+
const body = this.shown ? this.shown.render(width) : this.frame ? halfBlocks(this.frame, this.frame.width, this.frame.height / 2, getCapabilities().trueColor) : this.still.body(width, this.large);
|
|
525
|
+
const control = !FFMPEG ? "" : this.state === "playing" ? "‖ pause" : "▶ play";
|
|
526
|
+
const total = this.v.seconds ? clock(this.v.seconds) : "";
|
|
527
|
+
const time = this.child ? `${clock(this.position)}${total ? ` / ${total}` : ""} · no sound` : total;
|
|
528
|
+
const left = ` ${control ? `${c.accent(control)} ` : ""}${c.gray(`${control ? "· " : ""}${this.v.name}${time ? ` · ${time}` : ""} · click the picture to ${this.large ? "shrink" : "enlarge"}`)}`;
|
|
529
|
+
this.captionRow = body.length;
|
|
530
|
+
this.shownRows = body.length;
|
|
531
|
+
this.playTo = control ? 1 + visibleWidth(control) : -1;
|
|
532
|
+
this.openFrom = visibleWidth(left) + 3;
|
|
533
|
+
return [...body, truncateToWidth(`${left} ${c.accent("▸ open in player")}`, width)];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Stop whatever video is playing, for example when the UI closes. */
|
|
537
|
+
export function stopVideos() {
|
|
538
|
+
playing?.stop();
|
|
539
|
+
}
|
|
540
|
+
const VIDEO_FILE = /\.(mp4|m4v|mov|webm|mkv)$/i;
|
|
541
|
+
export const isVideoFile = (name) => VIDEO_FILE.test(name);
|
|
542
|
+
const IMAGE_FILE = /\.(png|jpe?g|gif|webp)$/i;
|
|
543
|
+
export const isImageFile = (name) => IMAGE_FILE.test(name);
|
|
544
|
+
export const mimeOf = (name) => (/\.png$/i.test(name) ? "image/png" : /\.jpe?g$/i.test(name) ? "image/jpeg" : /\.gif$/i.test(name) ? "image/gif" : /\.webp$/i.test(name) ? "image/webp" : "");
|
|
545
|
+
/** The pictures an answer points at with . Code blocks are left alone. */
|
|
546
|
+
export function picturesIn(markdown, max = 6) {
|
|
547
|
+
const text = markdown.replace(/```[\s\S]*?(```|$)/g, "").replace(/`[^`\n]*`/g, "");
|
|
548
|
+
const found = [];
|
|
549
|
+
for (const m of text.matchAll(/!\[([^\]\n]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"\n]*")?\s*\)/g)) {
|
|
550
|
+
let target = m[2];
|
|
551
|
+
if (/^data:/i.test(target))
|
|
552
|
+
continue;
|
|
553
|
+
const remote = /^https?:\/\//i.test(target);
|
|
554
|
+
if (!remote) {
|
|
555
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !/^file:/i.test(target))
|
|
556
|
+
continue;
|
|
557
|
+
target = target.replace(/^file:\/\//i, "");
|
|
558
|
+
try {
|
|
559
|
+
target = decodeURIComponent(target);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// keep it as written
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!found.some((f) => f.target === target))
|
|
566
|
+
found.push({ caption: m[1].trim(), target, remote });
|
|
567
|
+
if (found.length >= max)
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
return found;
|
|
571
|
+
}
|
package/src/tui/run.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PineConfig } from "../config.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Open the terminal UI. It attaches to a running daemon; if none answers, it starts one inside
|
|
4
|
+
* this process (logging to data/pine.log) and stops it again on exit.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runTui(cfg: PineConfig, opts?: {
|
|
7
|
+
folder?: string;
|
|
8
|
+
}): Promise<void>;
|
package/src/tui/run.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ProcessTerminal } from "@earendil-works/pi-tui";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { findServer, listServers, piMcp, serverStates } from "../mcp-config.js";
|
|
6
|
+
import { PineTui } from "./app.js";
|
|
7
|
+
import { Client } from "./client.js";
|
|
8
|
+
const reachable = async (url) => {
|
|
9
|
+
try {
|
|
10
|
+
return (await fetch(url, { signal: AbortSignal.timeout(800) })).ok;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Open the terminal UI. It attaches to a running daemon; if none answers, it starts one inside
|
|
18
|
+
* this process (logging to data/pine.log) and stops it again on exit.
|
|
19
|
+
*/
|
|
20
|
+
export async function runTui(cfg, opts = {}) {
|
|
21
|
+
if (!cfg.http)
|
|
22
|
+
throw new Error("the terminal UI needs the control API; remove \"http\": false from pine.json");
|
|
23
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY)
|
|
24
|
+
throw new Error("the terminal UI needs an interactive terminal");
|
|
25
|
+
const base = `http://${cfg.http.host === "0.0.0.0" ? "127.0.0.1" : cfg.http.host}:${cfg.http.port}`;
|
|
26
|
+
const cleanup = [];
|
|
27
|
+
let token;
|
|
28
|
+
let mode = "attached";
|
|
29
|
+
if (await reachable(`${base}/health`)) {
|
|
30
|
+
const tokenFile = path.join(cfg.dataDir, "http-token");
|
|
31
|
+
token = cfg.http.token ?? fs.readFileSync(tokenFile, "utf8").trim();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// A provider called "dev" is the claude -p stand-in; bring it up too if nothing serves it.
|
|
35
|
+
try {
|
|
36
|
+
const models = JSON.parse(fs.readFileSync(path.join(cfg.home, "models.json"), "utf8"));
|
|
37
|
+
const dev = models.providers?.dev?.baseUrl;
|
|
38
|
+
const uses = [cfg.defaults.model, ...Object.values(cfg.agents).map((a) => a.model)].some((m) => m?.startsWith("dev/"));
|
|
39
|
+
if (dev && uses && !(await reachable(`${dev}/models`))) {
|
|
40
|
+
const { startClaudeModel } = await import("../dev/claude-model.js");
|
|
41
|
+
const server = await startClaudeModel({ port: Number(new URL(dev).port) });
|
|
42
|
+
cleanup.push(() => server.close());
|
|
43
|
+
mode = "embedded daemon + claude -p";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// No models.json or no dev provider: nothing to start.
|
|
48
|
+
}
|
|
49
|
+
const { startPine } = await import("../pine.js");
|
|
50
|
+
const pine = await startPine({ config: cfg, logFile: path.join(cfg.dataDir, "pine.log") });
|
|
51
|
+
cleanup.push(() => pine.stop());
|
|
52
|
+
token = pine.token;
|
|
53
|
+
if (mode === "attached")
|
|
54
|
+
mode = "embedded daemon";
|
|
55
|
+
}
|
|
56
|
+
const done = Promise.withResolvers();
|
|
57
|
+
const app = new PineTui({
|
|
58
|
+
client: new Client(base, token),
|
|
59
|
+
terminal: new ProcessTerminal(),
|
|
60
|
+
mode,
|
|
61
|
+
mcp: {
|
|
62
|
+
// Read the files again each time: the daemon may have added a server since this UI started.
|
|
63
|
+
list: async (states = true) => {
|
|
64
|
+
const now = loadConfig(cfg.home);
|
|
65
|
+
if (!states)
|
|
66
|
+
return listServers(now).map((s) => ({ ...s, state: s.enabled ? undefined : "disabled" }));
|
|
67
|
+
const found = await serverStates(now);
|
|
68
|
+
return listServers(now).map((s) => ({ ...s, state: s.enabled ? (found[s.name] ?? (s.remote ? "unknown" : "local")) : "disabled" }));
|
|
69
|
+
},
|
|
70
|
+
login: async (name, onOutput) => {
|
|
71
|
+
const now = loadConfig(cfg.home);
|
|
72
|
+
const server = findServer(now, name);
|
|
73
|
+
if (!server?.url)
|
|
74
|
+
return onOutput(server ? `"${name}" is a local server and has no sign-in` : `no server "${name}"`), 1;
|
|
75
|
+
return (await piMcp(now, { [name]: server }, ["login", name], { capture: true, onOutput })).code;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
onQuit: async () => {
|
|
79
|
+
for (const fn of cleanup.reverse())
|
|
80
|
+
await Promise.resolve(fn()).catch(() => { });
|
|
81
|
+
done.resolve();
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
process.on("SIGTERM", () => void app.stop());
|
|
85
|
+
try {
|
|
86
|
+
await app.start();
|
|
87
|
+
// Started as "pine <folder>": the same tabs as always, and one on that folder.
|
|
88
|
+
if (opts.folder)
|
|
89
|
+
await app.openFolder(opts.folder);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
await app.stop();
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
await done.promise;
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|