omp-mobile 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 +201 -0
- package/NOTICE +6 -0
- package/README.md +73 -2
- package/package.json +47 -4
- package/src/admission.mjs +207 -0
- package/src/attention.mjs +243 -0
- package/src/omp-sessions.mjs +203 -0
- package/src/qr.mjs +67 -0
- package/src/rpc-session.mjs +256 -0
- package/src/server.mjs +818 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention stream: the moments a phone should hear about while the app is in
|
|
3
|
+
* the background — a tool approval or another question waiting for an answer,
|
|
4
|
+
* the answer (or cancellation) that settles it, and an agent run that finished
|
|
5
|
+
* or failed — folded from what every live session broadcasts, across all
|
|
6
|
+
* sessions, and served as one Server-Sent Events stream (`GET /events`).
|
|
7
|
+
*
|
|
8
|
+
* The tracker reads the same payloads `SessionEntry.broadcast` sends its
|
|
9
|
+
* WebSocket clients (`{type: "event" | "approval" | "approval_resolved" |
|
|
10
|
+
* "notice" | "closed", ...}`), so a frame never depends on a client being
|
|
11
|
+
* connected to that session.
|
|
12
|
+
*/
|
|
13
|
+
import { hostname } from "node:os";
|
|
14
|
+
|
|
15
|
+
/** @typedef {"approval" | "question" | "resolved" | "done" | "failed"} AttentionKind */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} AttentionFrame
|
|
19
|
+
* @property {AttentionKind} kind
|
|
20
|
+
* @property {string} key Waiting-item identity: the extension request id; `run:<n>` for done/failed.
|
|
21
|
+
* @property {string} sessionId
|
|
22
|
+
* @property {string} sessionTitle
|
|
23
|
+
* @property {string} machine This computer's hostname.
|
|
24
|
+
* @property {string} text Human text: the question, the final answer, or the failure.
|
|
25
|
+
* @property {number} time Epoch ms.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {{id: string, title?: string}} SessionLike */
|
|
29
|
+
|
|
30
|
+
/** The listener sends a comment this often so phones can tell a dead link from a quiet one. */
|
|
31
|
+
export const HEARTBEAT_MS = 25_000;
|
|
32
|
+
/** OMP's tool approval is a `select` whose title starts with this. */
|
|
33
|
+
export const TOOL_APPROVAL_PREFIX = "Allow tool:";
|
|
34
|
+
const ASKING = new Set(["select", "confirm", "input", "editor"]);
|
|
35
|
+
const TEXT_MAX = 280;
|
|
36
|
+
|
|
37
|
+
function clip(value) {
|
|
38
|
+
const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
39
|
+
return text.length > TEXT_MAX ? `${text.slice(0, TEXT_MAX - 1)}…` : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textOf(content) {
|
|
43
|
+
if (typeof content === "string") return content;
|
|
44
|
+
if (!Array.isArray(content)) return "";
|
|
45
|
+
return content
|
|
46
|
+
.map((part) => (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" ? part.text : ""))
|
|
47
|
+
.join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether an extension UI request is OMP's tool approval rather than another question.
|
|
52
|
+
* @param {{method?: string, title?: unknown}} request
|
|
53
|
+
*/
|
|
54
|
+
export function isToolApproval(request) {
|
|
55
|
+
return request.method === "select" && typeof request.title === "string" && request.title.startsWith(TOOL_APPROVAL_PREFIX);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function lastAssistant(messages) {
|
|
59
|
+
if (!Array.isArray(messages)) return undefined;
|
|
60
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
61
|
+
if (messages[i]?.role === "assistant") return messages[i];
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Folds session broadcasts into attention frames and fans them out. */
|
|
67
|
+
export class AttentionTracker {
|
|
68
|
+
/** @param {{machine?: string, now?: () => number}} [options] */
|
|
69
|
+
constructor(options = {}) {
|
|
70
|
+
this.machine = options.machine ?? hostname();
|
|
71
|
+
this.now = options.now ?? (() => Date.now());
|
|
72
|
+
/** @type {Map<string, {open: Map<string, AttentionFrame>, runs: number, error: string, last: object | undefined}>} */
|
|
73
|
+
this.sessions = new Map();
|
|
74
|
+
/** @type {Set<(frame: AttentionFrame) => void>} */
|
|
75
|
+
this.subscribers = new Set();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Approvals and questions still waiting, across all sessions, oldest first. */
|
|
79
|
+
pending() {
|
|
80
|
+
const open = [];
|
|
81
|
+
for (const state of this.sessions.values()) open.push(...state.open.values());
|
|
82
|
+
return open.sort((a, b) => a.time - b.time);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {(frame: AttentionFrame) => void} listener
|
|
87
|
+
* @returns {() => void} unsubscribe
|
|
88
|
+
*/
|
|
89
|
+
subscribe(listener) {
|
|
90
|
+
this.subscribers.add(listener);
|
|
91
|
+
return () => this.subscribers.delete(listener);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Drop a session that is gone, resolving whatever it still had open. */
|
|
95
|
+
forget(session) {
|
|
96
|
+
this.observe(session, { type: "closed" });
|
|
97
|
+
this.sessions.delete(session.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold one payload a session broadcast.
|
|
102
|
+
* @param {SessionLike} session
|
|
103
|
+
* @param {{type: string, [key: string]: any}} payload
|
|
104
|
+
* @returns {AttentionFrame[]} the frames it produced
|
|
105
|
+
*/
|
|
106
|
+
observe(session, payload) {
|
|
107
|
+
const state = this.#state(session.id);
|
|
108
|
+
const frames = [];
|
|
109
|
+
const emit = (kind, key, text) => {
|
|
110
|
+
const frame = {
|
|
111
|
+
kind,
|
|
112
|
+
key,
|
|
113
|
+
sessionId: session.id,
|
|
114
|
+
sessionTitle: session.title ?? "",
|
|
115
|
+
machine: this.machine,
|
|
116
|
+
text: clip(text),
|
|
117
|
+
time: this.now(),
|
|
118
|
+
};
|
|
119
|
+
frames.push(frame);
|
|
120
|
+
return frame;
|
|
121
|
+
};
|
|
122
|
+
switch (payload?.type) {
|
|
123
|
+
case "approval": {
|
|
124
|
+
const request = payload.request ?? {};
|
|
125
|
+
if (typeof request.id !== "string" || !ASKING.has(request.method)) break;
|
|
126
|
+
if (state.open.has(request.id)) break;
|
|
127
|
+
const approval = isToolApproval(request);
|
|
128
|
+
const title = typeof request.title === "string" ? request.title : "";
|
|
129
|
+
// OMP's tool approval title is `Allow tool: bash\nCommand: echo hi`: the tool, then its details.
|
|
130
|
+
const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title).split("\n").map((line) => line.trim());
|
|
131
|
+
const text = [...lines, request.message].filter(Boolean).join(" · ");
|
|
132
|
+
state.open.set(request.id, emit(approval ? "approval" : "question", request.id, text));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "approval_resolved": {
|
|
136
|
+
if (typeof payload.id !== "string" || !state.open.delete(payload.id)) break;
|
|
137
|
+
emit("resolved", payload.id, typeof payload.reason === "string" ? payload.reason : "answered");
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "closed": {
|
|
141
|
+
for (const key of [...state.open.keys()]) {
|
|
142
|
+
state.open.delete(key);
|
|
143
|
+
emit("resolved", key, "closed");
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "notice": {
|
|
148
|
+
if (payload.level === "error") state.error = typeof payload.message === "string" ? payload.message : "error";
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "event":
|
|
152
|
+
this.#event(state, payload.event ?? {}, emit);
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
}
|
|
156
|
+
for (const frame of frames) for (const listener of this.subscribers) listener(frame);
|
|
157
|
+
return frames;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#event(state, event, emit) {
|
|
161
|
+
switch (event.type) {
|
|
162
|
+
case "agent_start":
|
|
163
|
+
state.error = "";
|
|
164
|
+
state.last = undefined;
|
|
165
|
+
return;
|
|
166
|
+
case "notice":
|
|
167
|
+
if (event.level === "error") state.error = typeof event.message === "string" ? event.message : "error";
|
|
168
|
+
return;
|
|
169
|
+
case "message_end":
|
|
170
|
+
if (event.message?.role === "assistant") state.last = event.message;
|
|
171
|
+
return;
|
|
172
|
+
case "agent_end": {
|
|
173
|
+
state.runs += 1;
|
|
174
|
+
const key = `run:${state.runs}`;
|
|
175
|
+
const last = lastAssistant(event.messages) ?? state.last;
|
|
176
|
+
const stop = last?.stopReason;
|
|
177
|
+
if (stop === "error" || stop === "aborted") {
|
|
178
|
+
const reason = typeof last.errorMessage === "string" && last.errorMessage !== "" ? last.errorMessage : stop === "aborted" ? "Aborted" : "Failed";
|
|
179
|
+
emit("failed", key, reason);
|
|
180
|
+
} else if (state.error !== "") {
|
|
181
|
+
emit("failed", key, state.error);
|
|
182
|
+
} else {
|
|
183
|
+
emit("done", key, textOf(last?.content));
|
|
184
|
+
}
|
|
185
|
+
state.error = "";
|
|
186
|
+
state.last = undefined;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
default:
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#state(sessionId) {
|
|
194
|
+
let state = this.sessions.get(sessionId);
|
|
195
|
+
if (state === undefined) {
|
|
196
|
+
state = { open: new Map(), runs: 0, error: "", last: undefined };
|
|
197
|
+
this.sessions.set(sessionId, state);
|
|
198
|
+
}
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One SSE frame of data. */
|
|
204
|
+
export function sseData(frame) {
|
|
205
|
+
return `data: ${JSON.stringify(frame)}\n\n`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Serve the stream on an authorized request: open approvals/questions first,
|
|
210
|
+
* then `event: live`, then frames as they happen, with a heartbeat comment.
|
|
211
|
+
* @param {AttentionTracker} tracker
|
|
212
|
+
* @param {import("node:http").IncomingMessage} req
|
|
213
|
+
* @param {import("node:http").ServerResponse} res
|
|
214
|
+
* @param {{heartbeatMs?: number}} [options]
|
|
215
|
+
* @returns {() => void} ends the stream
|
|
216
|
+
*/
|
|
217
|
+
export function serveEvents(tracker, req, res, options = {}) {
|
|
218
|
+
res.writeHead(200, {
|
|
219
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
220
|
+
"cache-control": "no-store",
|
|
221
|
+
connection: "keep-alive",
|
|
222
|
+
"x-accel-buffering": "no",
|
|
223
|
+
});
|
|
224
|
+
req.socket.setNoDelay(true);
|
|
225
|
+
req.socket.setKeepAlive(true, 30_000);
|
|
226
|
+
res.write(": omp-mobile attention\n\n");
|
|
227
|
+
for (const frame of tracker.pending()) res.write(sseData(frame));
|
|
228
|
+
res.write("event: live\ndata: {}\n\n");
|
|
229
|
+
const off = tracker.subscribe((frame) => res.write(sseData(frame)));
|
|
230
|
+
const heartbeat = setInterval(() => res.write(": ping\n\n"), options.heartbeatMs ?? HEARTBEAT_MS);
|
|
231
|
+
let ended = false;
|
|
232
|
+
const end = () => {
|
|
233
|
+
if (ended) return;
|
|
234
|
+
ended = true;
|
|
235
|
+
off();
|
|
236
|
+
clearInterval(heartbeat);
|
|
237
|
+
if (!res.writableEnded) res.end();
|
|
238
|
+
};
|
|
239
|
+
req.on("close", end);
|
|
240
|
+
res.on("close", end);
|
|
241
|
+
res.on("error", end);
|
|
242
|
+
return end;
|
|
243
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions OMP wrote outside this daemon: the terminal sessions in OMP's own
|
|
3
|
+
* session directory, and which of them another omp process holds open.
|
|
4
|
+
*
|
|
5
|
+
* Reads only what OMP documents as its on-disk contract: one directory per
|
|
6
|
+
* cwd under `<omp home>/agent/sessions`, JSONL files that begin with an
|
|
7
|
+
* optional fixed-width `title` slot and the `session` header, and the owner
|
|
8
|
+
* lock each writing process holds in `<omp home>/run/session-owners/<id>.lock`.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** How far into a file the first prompt is looked for; headers and setup entries come first. */
|
|
15
|
+
const TITLE_SCAN_BYTES = 512 * 1024;
|
|
16
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
17
|
+
const LSOF_TIMEOUT_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
/** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, sessionFile: string}} OmpSession */
|
|
20
|
+
|
|
21
|
+
/** Text of an OMP message content field (string or content-part array). */
|
|
22
|
+
function textOfContent(content) {
|
|
23
|
+
if (typeof content === "string") return content;
|
|
24
|
+
if (Array.isArray(content)) {
|
|
25
|
+
return content
|
|
26
|
+
.map((part) => {
|
|
27
|
+
if (typeof part === "string") return part;
|
|
28
|
+
if (part && typeof part === "object" && typeof part.text === "string") return part.text;
|
|
29
|
+
return "";
|
|
30
|
+
})
|
|
31
|
+
.join("");
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The first user prompt, as one line of at most 80 characters. */
|
|
37
|
+
export function promptTitle(content) {
|
|
38
|
+
return oneLine(textOfContent(content));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A title as one line of at most 80 characters. OMP's title cards start with
|
|
43
|
+
* a Nerd Font icon (private-use code points) a phone font has no glyph for.
|
|
44
|
+
*/
|
|
45
|
+
function oneLine(text) {
|
|
46
|
+
return text
|
|
47
|
+
.replace(/[\uE000-\uF8FF\u{F0000}-\u{10FFFF}]/gu, "")
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.slice(0, 80);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a session file's identity and title.
|
|
55
|
+
* Undefined when the file has no `session` header (not an OMP session, or still empty).
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @returns {Promise<OmpSession | undefined>}
|
|
58
|
+
*/
|
|
59
|
+
export async function readSessionFile(file) {
|
|
60
|
+
const handle = await open(file, "r");
|
|
61
|
+
try {
|
|
62
|
+
const info = await handle.stat();
|
|
63
|
+
let header;
|
|
64
|
+
let slotTitle = "";
|
|
65
|
+
let firstPrompt = "";
|
|
66
|
+
let rest = "";
|
|
67
|
+
let offset = 0;
|
|
68
|
+
const buffer = Buffer.alloc(CHUNK_BYTES);
|
|
69
|
+
scan: while (offset < TITLE_SCAN_BYTES) {
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, CHUNK_BYTES, offset);
|
|
71
|
+
if (bytesRead === 0) break;
|
|
72
|
+
offset += bytesRead;
|
|
73
|
+
rest += buffer.toString("utf8", 0, bytesRead);
|
|
74
|
+
const lines = rest.split("\n");
|
|
75
|
+
rest = lines.pop() ?? "";
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
let entry;
|
|
78
|
+
try {
|
|
79
|
+
entry = JSON.parse(line);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry?.type === "title" && header === undefined) {
|
|
84
|
+
if (typeof entry.title === "string") slotTitle = oneLine(entry.title);
|
|
85
|
+
} else if (entry?.type === "session") {
|
|
86
|
+
header = entry;
|
|
87
|
+
if (slotTitle === "" && typeof entry.title === "string") slotTitle = oneLine(entry.title);
|
|
88
|
+
if (slotTitle !== "") break scan;
|
|
89
|
+
} else if (header === undefined) {
|
|
90
|
+
// Legacy files begin with the header; anything else first is not a session.
|
|
91
|
+
return undefined;
|
|
92
|
+
} else if (entry?.type === "message" && entry.message?.role === "user") {
|
|
93
|
+
firstPrompt = promptTitle(entry.message.content);
|
|
94
|
+
if (firstPrompt !== "") break scan;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (header === undefined || typeof header.id !== "string" || typeof header.cwd !== "string") return undefined;
|
|
99
|
+
const created = Date.parse(header.timestamp);
|
|
100
|
+
return {
|
|
101
|
+
id: header.id,
|
|
102
|
+
title: slotTitle || firstPrompt || basename(header.cwd) || "session",
|
|
103
|
+
cwd: header.cwd,
|
|
104
|
+
createdAt: Number.isFinite(created) ? created : info.birthtimeMs,
|
|
105
|
+
updatedAt: info.mtimeMs,
|
|
106
|
+
sessionFile: file,
|
|
107
|
+
};
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The most recently written sessions under OMP's session root, newest first.
|
|
115
|
+
* A missing root is no sessions; unreadable or foreign files are skipped.
|
|
116
|
+
* @param {string} root `<omp home>/agent/sessions`
|
|
117
|
+
* @param {{limit?: number}} [options]
|
|
118
|
+
* @returns {Promise<OmpSession[]>}
|
|
119
|
+
*/
|
|
120
|
+
export async function listOmpSessions(root, { limit = 50 } = {}) {
|
|
121
|
+
let dirs;
|
|
122
|
+
try {
|
|
123
|
+
dirs = await readdir(root, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
/** @type {{file: string, mtime: number}[]} */
|
|
128
|
+
const files = [];
|
|
129
|
+
await Promise.all(
|
|
130
|
+
dirs
|
|
131
|
+
.filter((dir) => dir.isDirectory())
|
|
132
|
+
.map(async (dir) => {
|
|
133
|
+
const path = join(root, dir.name);
|
|
134
|
+
let names;
|
|
135
|
+
try {
|
|
136
|
+
names = await readdir(path);
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await Promise.all(
|
|
141
|
+
names
|
|
142
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
143
|
+
.map(async (name) => {
|
|
144
|
+
const file = join(path, name);
|
|
145
|
+
try {
|
|
146
|
+
files.push({ file, mtime: (await stat(file)).mtimeMs });
|
|
147
|
+
} catch {
|
|
148
|
+
// Removed while listing.
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
155
|
+
const sessions = [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const { file } of files) {
|
|
158
|
+
if (sessions.length >= limit) break;
|
|
159
|
+
let session;
|
|
160
|
+
try {
|
|
161
|
+
session = await readSessionFile(file);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// A moved session can leave an older copy; the newest file wins.
|
|
166
|
+
if (session === undefined || seen.has(session.id)) continue;
|
|
167
|
+
seen.add(session.id);
|
|
168
|
+
sessions.push(session);
|
|
169
|
+
}
|
|
170
|
+
return sessions;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Session ids whose owner lock some process holds open, with that process id.
|
|
175
|
+
* `undefined` when ownership cannot be read (no `lsof`), which callers must not treat as "nobody".
|
|
176
|
+
* @param {string} ownersDir `<omp home>/run/session-owners`
|
|
177
|
+
* @param {{lsof?: string}} [options]
|
|
178
|
+
* @returns {Promise<Map<string, number> | undefined>}
|
|
179
|
+
*/
|
|
180
|
+
export function sessionOwners(ownersDir, { lsof = "lsof" } = {}) {
|
|
181
|
+
return new Promise((resolveOwners) => {
|
|
182
|
+
// -F pn: one `p<pid>` line per process, then `n<path>` per open file.
|
|
183
|
+
execFile(lsof, ["-n", "-w", "-F", "pn", "+d", ownersDir], { timeout: LSOF_TIMEOUT_MS }, (error, stdout) => {
|
|
184
|
+
// lsof exits 1 when nothing in the directory is open; that is an answer.
|
|
185
|
+
if (error && (typeof error.code !== "number" || stdout === "")) {
|
|
186
|
+
resolveOwners(error.code === 1 ? new Map() : undefined);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
resolveOwners(parseLsof(stdout));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {string} output `lsof -F pn` output */
|
|
195
|
+
export function parseLsof(output) {
|
|
196
|
+
const owners = new Map();
|
|
197
|
+
let pid = 0;
|
|
198
|
+
for (const line of output.split("\n")) {
|
|
199
|
+
if (line.startsWith("p")) pid = Number(line.slice(1));
|
|
200
|
+
else if (line.startsWith("n") && line.endsWith(".lock") && pid > 0) owners.set(basename(line.slice(1), ".lock"), pid);
|
|
201
|
+
}
|
|
202
|
+
return owners;
|
|
203
|
+
}
|
package/src/qr.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal QR codes for the pairing URI, drawn with Unicode half blocks.
|
|
3
|
+
*
|
|
4
|
+
* Each text line carries two module rows: "▀" is a dark top half, "▄" a dark
|
|
5
|
+
* bottom half, "█" both and a space neither. Dark modules are the terminal's
|
|
6
|
+
* foreground and the quiet zone is plain spaces, so on a light terminal the
|
|
7
|
+
* code is dark on light, and on a dark terminal it is the same code inverted,
|
|
8
|
+
* which the phone scanners (ML Kit, Vision) read as well. No colors are used,
|
|
9
|
+
* so the output survives logs, tmux and terminals without ANSI support.
|
|
10
|
+
*/
|
|
11
|
+
import qrcode from "qrcode-generator";
|
|
12
|
+
|
|
13
|
+
/** Modules of blank margin on every side. */
|
|
14
|
+
export const QUIET_ZONE = 2;
|
|
15
|
+
|
|
16
|
+
/** Error correction level: M survives a slightly blurry photo of a screen. */
|
|
17
|
+
const LEVEL = "M";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The QR module matrix for `text`, `true` for a dark module.
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {boolean[][]}
|
|
23
|
+
*/
|
|
24
|
+
export function qrMatrix(text) {
|
|
25
|
+
// Type 0 picks the smallest version that fits; byte mode keeps URIs intact.
|
|
26
|
+
const code = qrcode(0, LEVEL);
|
|
27
|
+
code.addData(text, "Byte");
|
|
28
|
+
code.make();
|
|
29
|
+
const size = code.getModuleCount();
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (let row = 0; row < size; row += 1) {
|
|
32
|
+
const cells = [];
|
|
33
|
+
for (let col = 0; col < size; col += 1) cells.push(code.isDark(row, col));
|
|
34
|
+
rows.push(cells);
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render `text` as a QR code of half blocks with a blank quiet zone.
|
|
41
|
+
* Every line has the same width; lines are joined with "\n" (no trailing newline).
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function renderQr(text) {
|
|
46
|
+
const matrix = qrMatrix(text);
|
|
47
|
+
const size = matrix.length;
|
|
48
|
+
const total = size + QUIET_ZONE * 2;
|
|
49
|
+
/** Whether the module at (row, col) of the padded grid is dark. */
|
|
50
|
+
const dark = (row, col) => {
|
|
51
|
+
const r = row - QUIET_ZONE;
|
|
52
|
+
const c = col - QUIET_ZONE;
|
|
53
|
+
return r >= 0 && r < size && c >= 0 && c < size && matrix[r][c] === true;
|
|
54
|
+
};
|
|
55
|
+
const lines = [];
|
|
56
|
+
// An odd row count leaves the last line's bottom half outside the grid: blank.
|
|
57
|
+
for (let row = 0; row < total; row += 2) {
|
|
58
|
+
let line = "";
|
|
59
|
+
for (let col = 0; col < total; col += 1) {
|
|
60
|
+
const top = dark(row, col);
|
|
61
|
+
const bottom = row + 1 < total && dark(row + 1, col);
|
|
62
|
+
line += top ? (bottom ? "█" : "▀") : bottom ? "▄" : " ";
|
|
63
|
+
}
|
|
64
|
+
lines.push(line);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|