pendingyou 0.16.0 → 0.18.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/CHANGELOG.md +70 -0
- package/README.md +60 -2
- package/dist/api.js +13 -2
- package/dist/apps/codex-wake.js +76 -4
- package/dist/args.js +163 -1
- package/dist/herdr/command.js +301 -0
- package/dist/herdr/notice.js +67 -0
- package/dist/herdr/panes.js +121 -0
- package/dist/herdr/plugin.js +119 -0
- package/dist/herdr/popup.js +271 -0
- package/dist/herdr/socket.js +123 -0
- package/dist/herdr/watch.js +179 -0
- package/dist/herdr.js +243 -0
- package/dist/index.js +1 -0
- package/dist/init.js +5 -0
- package/dist/io.js +61 -0
- package/dist/machine-command.js +101 -0
- package/dist/machine.js +151 -0
- package/dist/main.js +6 -0
- package/dist/oauth.js +23 -12
- package/dist/pickup.js +7 -0
- package/dist/presence.js +43 -10
- package/dist/signin.js +6 -0
- package/dist/version.js +1 -1
- package/mod/hooks/register.js +38 -2
- package/mod/hooks/wake.js +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { APP_NAMES } from "../apps/ids.js";
|
|
4
|
+
import { configDir } from "../files.js";
|
|
5
|
+
import { cleanValue } from "../herdr.js";
|
|
6
|
+
import { headlessReason } from "../remote.js";
|
|
7
|
+
import { readWaiting } from "./panes.js";
|
|
8
|
+
import { HerdrError, herdrRequest } from "./socket.js";
|
|
9
|
+
export const REFRESH_MS = 3000;
|
|
10
|
+
export const DEMO = [
|
|
11
|
+
{
|
|
12
|
+
paneId: 'w1:p1',
|
|
13
|
+
workspaceId: 'w1',
|
|
14
|
+
tabId: 'w1:t1',
|
|
15
|
+
workspace: 'billing',
|
|
16
|
+
tab: 'webhooks',
|
|
17
|
+
app: 'claude-code',
|
|
18
|
+
agent: 'Wren',
|
|
19
|
+
herdrAgent: 'claude',
|
|
20
|
+
status: 'idle',
|
|
21
|
+
waiting: 2,
|
|
22
|
+
card: 'Deploy billing-webhooks to staging?',
|
|
23
|
+
cards: ['req_0f3a0000000000000001', 'req_9c1d0000000000000002'],
|
|
24
|
+
pressing: 'blocking',
|
|
25
|
+
asked: [],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
paneId: 'w2:p3',
|
|
29
|
+
workspaceId: 'w2',
|
|
30
|
+
tabId: 'w2:t1',
|
|
31
|
+
workspace: 'infra',
|
|
32
|
+
tab: 'review',
|
|
33
|
+
app: 'codex',
|
|
34
|
+
agent: 'infra',
|
|
35
|
+
herdrAgent: 'codex',
|
|
36
|
+
status: 'done',
|
|
37
|
+
waiting: 1,
|
|
38
|
+
card: 'Which DNS host for the new domain?',
|
|
39
|
+
cards: ['req_77b20000000000000003'],
|
|
40
|
+
pressing: 'now',
|
|
41
|
+
asked: [],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
paneId: 'w3:p1',
|
|
45
|
+
workspaceId: 'w3',
|
|
46
|
+
tabId: 'w3:t2',
|
|
47
|
+
workspace: 'docs',
|
|
48
|
+
tab: 'site',
|
|
49
|
+
app: 'opencode',
|
|
50
|
+
agent: 'Sam',
|
|
51
|
+
herdrAgent: 'opencode',
|
|
52
|
+
status: 'idle',
|
|
53
|
+
waiting: 1,
|
|
54
|
+
card: 'Publish the pricing page today?',
|
|
55
|
+
cards: ['req_5e4c0000000000000004'],
|
|
56
|
+
pressing: 'today',
|
|
57
|
+
asked: [],
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
export function popupKey(state, key) {
|
|
61
|
+
const count = state.panes.length;
|
|
62
|
+
const move = (by) => [
|
|
63
|
+
{ ...state, selected: count ? (state.selected + by + count) % count : 0, note: '' },
|
|
64
|
+
{ kind: 'none' },
|
|
65
|
+
];
|
|
66
|
+
const chosen = state.panes[state.selected];
|
|
67
|
+
switch (key) {
|
|
68
|
+
case 'q':
|
|
69
|
+
case '\x1b':
|
|
70
|
+
case '\x03':
|
|
71
|
+
case '\x04':
|
|
72
|
+
return [state, { kind: 'close' }];
|
|
73
|
+
case 'j':
|
|
74
|
+
case '\x1b[B':
|
|
75
|
+
case '\x1bOB':
|
|
76
|
+
return move(1);
|
|
77
|
+
case 'k':
|
|
78
|
+
case '\x1b[A':
|
|
79
|
+
case '\x1bOA':
|
|
80
|
+
return move(-1);
|
|
81
|
+
case 'r':
|
|
82
|
+
return [state, { kind: 'refresh' }];
|
|
83
|
+
case 'a':
|
|
84
|
+
return [state, { kind: 'queue' }];
|
|
85
|
+
case '\r':
|
|
86
|
+
case '\n':
|
|
87
|
+
case 'o':
|
|
88
|
+
return chosen ? [state, { kind: 'open', pane: chosen }] : [state, { kind: 'queue' }];
|
|
89
|
+
case 't':
|
|
90
|
+
return chosen ? [state, { kind: 'focus', pane: chosen }] : [state, { kind: 'none' }];
|
|
91
|
+
default:
|
|
92
|
+
return [state, { kind: 'none' }];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export const hyperlink = (url) => `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
|
|
96
|
+
export const cardUrl = (origin, requestId) => `${origin}/app/r/${encodeURIComponent(requestId)}`;
|
|
97
|
+
const appName = (app) => (app ? APP_NAMES[app] : 'an agent');
|
|
98
|
+
export function popupLines(state, width) {
|
|
99
|
+
const room = Math.max(30, width - 2);
|
|
100
|
+
const fit = (text, max = room) => cleanValue(text, max);
|
|
101
|
+
const count = state.panes.length;
|
|
102
|
+
const heading = count
|
|
103
|
+
? `Pending You · ${count === 1 ? '1 agent in Herdr is' : `${count} agents in Herdr are`} waiting on you${state.demo ? ' (demo)' : ''}`
|
|
104
|
+
: `Pending You${state.demo ? ' (demo)' : ''}`;
|
|
105
|
+
const lines = [`\x1b[1m${fit(heading)}\x1b[0m`, ''];
|
|
106
|
+
if (!count)
|
|
107
|
+
lines.push(fit('Nothing in Herdr is waiting on you.'), fit('Cards from your other assistants are in Pending You: press a to open it.'));
|
|
108
|
+
for (const [index, pane] of state.panes.entries()) {
|
|
109
|
+
const chosen = index === state.selected;
|
|
110
|
+
const tag = pane.pressing ?? '';
|
|
111
|
+
const title = fit(`⏳ ${pane.card ?? 'A card'}`, Math.max(10, room - tag.length - 3));
|
|
112
|
+
const gap = ' '.repeat(Math.max(1, room - 2 - [...title].length - tag.length));
|
|
113
|
+
const first = `${chosen ? '▸ ' : ' '}${title}${gap}${tag}`;
|
|
114
|
+
const where = [
|
|
115
|
+
pane.agent ? `${pane.agent} (${appName(pane.app)})` : appName(pane.app),
|
|
116
|
+
`${pane.workspace} › ${pane.tab}`,
|
|
117
|
+
pane.paneId,
|
|
118
|
+
...(pane.waiting > 1 ? [`${pane.waiting} waiting`] : []),
|
|
119
|
+
].join(' · ');
|
|
120
|
+
lines.push(chosen ? `\x1b[7m${first}\x1b[0m` : first, ` ${fit(where, room - 4)}`);
|
|
121
|
+
}
|
|
122
|
+
lines.push('', fit(`${count ? '↑↓ choose · enter open the card · t take me there · ' : ''}a all your cards · q close`));
|
|
123
|
+
if (state.note)
|
|
124
|
+
lines.push('', state.note);
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
const frame = (lines) => `\x1b[?25l\x1b[2J\x1b[H${lines.join('\r\n')}`;
|
|
128
|
+
export async function originFor(io, app, fallback) {
|
|
129
|
+
if (!app)
|
|
130
|
+
return fallback;
|
|
131
|
+
try {
|
|
132
|
+
const manifest = JSON.parse(await readFile(join(configDir(io), `${app}.json`), 'utf8'));
|
|
133
|
+
const origin = manifest?.origin;
|
|
134
|
+
return typeof origin === 'string' && /^https?:\/\/[^\s/]+$/.test(origin) ? origin : fallback;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return fallback;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function openPage(io, url, what) {
|
|
141
|
+
const opened = headlessReason(io) ? false : await io.openBrowser(url).catch(() => false);
|
|
142
|
+
return `${opened ? `Opened ${what} in your browser:` : `Open ${what}: Ctrl-click (or ⌘-click)`} ${hyperlink(url)}`;
|
|
143
|
+
}
|
|
144
|
+
export async function cardsPopup(io, place, options) {
|
|
145
|
+
const width = () => io.columns?.() ?? 80;
|
|
146
|
+
let state = { panes: [], selected: 0, note: '', demo: options.demo };
|
|
147
|
+
const load = async () => {
|
|
148
|
+
if (options.demo)
|
|
149
|
+
return [...DEMO];
|
|
150
|
+
if (!place.socket)
|
|
151
|
+
throw new HerdrError('unreachable', 'Run this from Herdr: no socket here.');
|
|
152
|
+
return (await readWaiting(place.socket)).waiting;
|
|
153
|
+
};
|
|
154
|
+
const refresh = async () => {
|
|
155
|
+
try {
|
|
156
|
+
const panes = await load();
|
|
157
|
+
const keep = state.panes[state.selected]?.paneId;
|
|
158
|
+
const at = panes.findIndex((pane) => pane.paneId === keep);
|
|
159
|
+
state = {
|
|
160
|
+
...state,
|
|
161
|
+
panes,
|
|
162
|
+
selected: at >= 0 ? at : Math.max(0, Math.min(state.selected, panes.length - 1)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
state = {
|
|
167
|
+
...state,
|
|
168
|
+
note: error instanceof HerdrError ? error.message : 'Herdr isn’t answering.',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
await refresh();
|
|
173
|
+
const draw = () => io.out(frame(popupLines(state, width())));
|
|
174
|
+
draw();
|
|
175
|
+
const act = async (action) => {
|
|
176
|
+
switch (action.kind) {
|
|
177
|
+
case 'close':
|
|
178
|
+
return true;
|
|
179
|
+
case 'refresh':
|
|
180
|
+
await refresh();
|
|
181
|
+
return false;
|
|
182
|
+
case 'queue': {
|
|
183
|
+
const origin = await originFor(io, state.panes[0]?.app ?? null, options.origin);
|
|
184
|
+
state = { ...state, note: await openPage(io, `${origin}/app`, 'your cards') };
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
case 'open': {
|
|
188
|
+
const requestId = action.pane.cards[0];
|
|
189
|
+
const origin = await originFor(io, action.pane.app, options.origin);
|
|
190
|
+
const url = requestId ? cardUrl(origin, requestId) : `${origin}/app`;
|
|
191
|
+
state = { ...state, note: await openPage(io, url, requestId ? 'the card' : 'your cards') };
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
case 'focus':
|
|
195
|
+
if (options.demo) {
|
|
196
|
+
state = { ...state, note: `Demo: this would take you to ${action.pane.paneId}.` };
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await herdrRequest(place.socket ?? '', 'pane.focus', { pane_id: action.pane.paneId });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
state = {
|
|
205
|
+
...state,
|
|
206
|
+
note: error instanceof HerdrError ? error.message : 'Herdr couldn’t go there.',
|
|
207
|
+
};
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const keys = io.keys?.()[Symbol.asyncIterator]();
|
|
215
|
+
if (!keys)
|
|
216
|
+
return 0;
|
|
217
|
+
let pending = keys.next();
|
|
218
|
+
try {
|
|
219
|
+
for (;;) {
|
|
220
|
+
const next = await Promise.race([
|
|
221
|
+
pending,
|
|
222
|
+
options.demo
|
|
223
|
+
? new Promise(() => { })
|
|
224
|
+
: io.sleep(REFRESH_MS, io.signal).then(() => 'tick'),
|
|
225
|
+
]);
|
|
226
|
+
if (io.signal.aborted)
|
|
227
|
+
return 0;
|
|
228
|
+
if (next === 'tick') {
|
|
229
|
+
await refresh();
|
|
230
|
+
draw();
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (next.done)
|
|
234
|
+
return 0;
|
|
235
|
+
pending = keys.next();
|
|
236
|
+
for (const key of splitKeys(next.value)) {
|
|
237
|
+
const [after, action] = popupKey(state, key);
|
|
238
|
+
state = after;
|
|
239
|
+
if (await act(action))
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
draw();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await keys.return?.();
|
|
247
|
+
io.out('\x1b[?25h');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const ESC = '\u001b';
|
|
251
|
+
export function splitKeys(chunk) {
|
|
252
|
+
const keys = [];
|
|
253
|
+
let index = 0;
|
|
254
|
+
while (index < chunk.length) {
|
|
255
|
+
const kind = chunk[index + 1];
|
|
256
|
+
if (chunk[index] === ESC && (kind === '[' || kind === 'O')) {
|
|
257
|
+
let end = index + 2;
|
|
258
|
+
if (kind === '[')
|
|
259
|
+
while (end < chunk.length && /[0-9;]/.test(chunk[end]))
|
|
260
|
+
end++;
|
|
261
|
+
if (end < chunk.length && /[A-Za-z~]/.test(chunk[end])) {
|
|
262
|
+
keys.push(chunk.slice(index, end + 1));
|
|
263
|
+
index = end + 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
keys.push(chunk[index]);
|
|
268
|
+
index++;
|
|
269
|
+
}
|
|
270
|
+
return keys;
|
|
271
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { PlainError } from "../errors.js";
|
|
3
|
+
export const REQUEST_MS = 5000;
|
|
4
|
+
const BUFFER_MAX = 16 * 1024 * 1024;
|
|
5
|
+
export class HerdrError extends PlainError {
|
|
6
|
+
name = 'HerdrError';
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
let sent = 0;
|
|
15
|
+
const nextId = () => `pendingyou-${process.pid}-${++sent}`;
|
|
16
|
+
function errorOf(message) {
|
|
17
|
+
const error = isObject(message.error) ? message.error : {};
|
|
18
|
+
return new HerdrError(typeof error.code === 'string' ? error.code : 'error', typeof error.message === 'string' ? error.message : 'Herdr refused it.');
|
|
19
|
+
}
|
|
20
|
+
export function herdrRequest(socketPath, method, params = {}, timeoutMs = REQUEST_MS) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const id = nextId();
|
|
23
|
+
let buffer = '';
|
|
24
|
+
let settled = false;
|
|
25
|
+
const socket = createConnection(socketPath);
|
|
26
|
+
const finish = (error, value) => {
|
|
27
|
+
if (settled)
|
|
28
|
+
return;
|
|
29
|
+
settled = true;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
socket.destroy();
|
|
32
|
+
if (error)
|
|
33
|
+
reject(error);
|
|
34
|
+
else
|
|
35
|
+
resolve(value);
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(() => finish(new HerdrError('timeout', `Herdr didn’t answer ${method} in time.`)), timeoutMs);
|
|
38
|
+
socket.setEncoding('utf8');
|
|
39
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
|
|
40
|
+
socket.on('data', (chunk) => {
|
|
41
|
+
buffer += chunk;
|
|
42
|
+
for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) {
|
|
43
|
+
const line = buffer.slice(0, end);
|
|
44
|
+
buffer = buffer.slice(end + 1);
|
|
45
|
+
let message;
|
|
46
|
+
try {
|
|
47
|
+
message = JSON.parse(line);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!isObject(message) || (message.id !== id && message.id !== ''))
|
|
53
|
+
continue;
|
|
54
|
+
if (message.error !== undefined)
|
|
55
|
+
return finish(errorOf(message));
|
|
56
|
+
return finish(null, message.result);
|
|
57
|
+
}
|
|
58
|
+
if (buffer.length > BUFFER_MAX)
|
|
59
|
+
finish(new HerdrError('too_large', 'Herdr’s answer was too big.'));
|
|
60
|
+
});
|
|
61
|
+
socket.on('error', (error) => finish(new HerdrError('unreachable', `Herdr isn’t reachable here (${error.code ?? 'error'}).`)));
|
|
62
|
+
socket.on('close', () => finish(new HerdrError('closed', 'Herdr closed the connection.')));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function herdrSubscribe(socketPath, subscriptions, onEvent) {
|
|
66
|
+
const id = nextId();
|
|
67
|
+
const socket = createConnection(socketPath);
|
|
68
|
+
let buffer = '';
|
|
69
|
+
let ended = false;
|
|
70
|
+
let start = () => { };
|
|
71
|
+
let failStart = () => { };
|
|
72
|
+
const started = new Promise((resolve, reject) => {
|
|
73
|
+
start = resolve;
|
|
74
|
+
failStart = reject;
|
|
75
|
+
});
|
|
76
|
+
started.catch(() => { });
|
|
77
|
+
let finish = () => { };
|
|
78
|
+
const done = new Promise((resolve) => {
|
|
79
|
+
finish = resolve;
|
|
80
|
+
});
|
|
81
|
+
const end = (how) => {
|
|
82
|
+
if (ended)
|
|
83
|
+
return;
|
|
84
|
+
ended = true;
|
|
85
|
+
failStart(new HerdrError(how, 'The subscription ended before it started.'));
|
|
86
|
+
socket.destroy();
|
|
87
|
+
finish(how);
|
|
88
|
+
};
|
|
89
|
+
socket.setEncoding('utf8');
|
|
90
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ id, method: 'events.subscribe', params: { subscriptions } })}\n`));
|
|
91
|
+
socket.on('data', (chunk) => {
|
|
92
|
+
buffer += chunk;
|
|
93
|
+
for (let at = buffer.indexOf('\n'); at !== -1; at = buffer.indexOf('\n')) {
|
|
94
|
+
const line = buffer.slice(0, at);
|
|
95
|
+
buffer = buffer.slice(at + 1);
|
|
96
|
+
let message;
|
|
97
|
+
try {
|
|
98
|
+
message = JSON.parse(line);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!isObject(message))
|
|
104
|
+
continue;
|
|
105
|
+
if (message.id === id || message.id === '') {
|
|
106
|
+
if (message.error !== undefined)
|
|
107
|
+
return end(errorOf(message).code === 'events_lost' ? 'lost' : 'refused');
|
|
108
|
+
start();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (typeof message.event === 'string' && isObject(message.data))
|
|
112
|
+
try {
|
|
113
|
+
onEvent({ event: message.event, data: message.data });
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
}
|
|
117
|
+
if (buffer.length > BUFFER_MAX)
|
|
118
|
+
end('lost');
|
|
119
|
+
});
|
|
120
|
+
socket.on('error', () => end('closed'));
|
|
121
|
+
socket.on('close', () => end('closed'));
|
|
122
|
+
return { started, done, stop: () => end('stopped') };
|
|
123
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { due, emptyNotices, look } from "./notice.js";
|
|
5
|
+
import { readWaiting } from "./panes.js";
|
|
6
|
+
import { agentView, pluginOff, viewOn } from "./plugin.js";
|
|
7
|
+
import { HerdrError, herdrRequest, herdrSubscribe } from "./socket.js";
|
|
8
|
+
export const LEASE_STALE_MS = 60_000;
|
|
9
|
+
export const RENEW_MS = 20_000;
|
|
10
|
+
const TICK_MS = 1000;
|
|
11
|
+
const AGAIN_MS = 2000;
|
|
12
|
+
export const SUBSCRIPTIONS = [
|
|
13
|
+
{ type: 'pane.updated' },
|
|
14
|
+
{ type: 'pane.closed' },
|
|
15
|
+
{ type: 'pane.moved' },
|
|
16
|
+
{ type: 'pane.focused' },
|
|
17
|
+
{ type: 'tab.focused' },
|
|
18
|
+
{ type: 'workspace.focused' },
|
|
19
|
+
];
|
|
20
|
+
const leasePath = (place) => join(place.stateDir, 'watch.lease');
|
|
21
|
+
export async function watching(place) {
|
|
22
|
+
return stat(leasePath(place)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
|
|
23
|
+
}
|
|
24
|
+
async function takeLease(place) {
|
|
25
|
+
const path = leasePath(place);
|
|
26
|
+
await mkdir(place.stateDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (await watching(place))
|
|
28
|
+
return null;
|
|
29
|
+
const id = randomBytes(8).toString('hex');
|
|
30
|
+
await rm(path, { force: true });
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(path, JSON.stringify({ id, pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function holder(place) {
|
|
40
|
+
try {
|
|
41
|
+
const held = JSON.parse(await readFile(leasePath(place), 'utf8'));
|
|
42
|
+
return typeof held.id === 'string' && typeof held.pid === 'number'
|
|
43
|
+
? { id: held.id, pid: held.pid }
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function renew(place, id) {
|
|
51
|
+
if ((await holder(place))?.id !== id)
|
|
52
|
+
return false;
|
|
53
|
+
const now = new Date();
|
|
54
|
+
await utimes(leasePath(place), now, now).catch(() => { });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
export async function stopWatcher(place) {
|
|
58
|
+
const held = await holder(place);
|
|
59
|
+
await rm(leasePath(place), { force: true }).catch(() => { });
|
|
60
|
+
if (!held)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
process.kill(held.pid, 'SIGTERM');
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
export async function pluginEnabled(socket, pluginId) {
|
|
69
|
+
try {
|
|
70
|
+
const result = await herdrRequest(socket, 'plugin.list', {
|
|
71
|
+
plugin_id: pluginId,
|
|
72
|
+
});
|
|
73
|
+
const plugins = Array.isArray(result?.plugins) ? result.plugins : [];
|
|
74
|
+
return plugins.some((plugin) => typeof plugin === 'object' &&
|
|
75
|
+
plugin !== null &&
|
|
76
|
+
plugin.plugin_id === pluginId &&
|
|
77
|
+
plugin.enabled === true);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof HerdrError && error.code === 'plugin_not_found')
|
|
81
|
+
return false;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function showToast(socket, toast) {
|
|
86
|
+
try {
|
|
87
|
+
const result = await herdrRequest(socket, 'notification.show', { title: toast.title, body: toast.body, sound: 'request' });
|
|
88
|
+
return result?.shown === true ? 'shown' : String(result?.reason ?? 'not shown');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return error instanceof HerdrError ? error.code : 'failed';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function reapplyView(place) {
|
|
95
|
+
if (!place.socket || !(await viewOn(place)))
|
|
96
|
+
return;
|
|
97
|
+
await herdrRequest(place.socket, 'agent.view.set', agentView(place.id)).catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
export async function watchStep(io, socket, state, options) {
|
|
100
|
+
let last = options.last;
|
|
101
|
+
let next = state;
|
|
102
|
+
if (options.read) {
|
|
103
|
+
const { snapshot, waiting } = await readWaiting(socket);
|
|
104
|
+
last = { waiting, viewing: snapshot.focusedTab };
|
|
105
|
+
next = look(next, last.waiting, io.now());
|
|
106
|
+
}
|
|
107
|
+
const decided = due(next, last.waiting, io.now(), last.viewing);
|
|
108
|
+
next = decided.state;
|
|
109
|
+
const toast = decided.toast ? await showToast(socket, decided.toast) : null;
|
|
110
|
+
return { state: next, last, toast };
|
|
111
|
+
}
|
|
112
|
+
export async function watch(io, place, options) {
|
|
113
|
+
if (!place.socket || (await pluginOff(place)))
|
|
114
|
+
return 0;
|
|
115
|
+
if (options.ensure) {
|
|
116
|
+
await reapplyView(place);
|
|
117
|
+
if (!(await watching(place)))
|
|
118
|
+
io.background(['herdr', 'watch']);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
const lease = await takeLease(place);
|
|
122
|
+
if (!lease)
|
|
123
|
+
return 0;
|
|
124
|
+
const socket = place.socket;
|
|
125
|
+
let state = emptyNotices();
|
|
126
|
+
let last = { waiting: [], viewing: null };
|
|
127
|
+
try {
|
|
128
|
+
while (!io.signal.aborted) {
|
|
129
|
+
if (!(await renew(place, lease)) || !(await pluginEnabled(socket, place.id)))
|
|
130
|
+
return 0;
|
|
131
|
+
const live = { changed: true, ending: null };
|
|
132
|
+
const subscription = herdrSubscribe(socket, SUBSCRIPTIONS, () => {
|
|
133
|
+
live.changed = true;
|
|
134
|
+
});
|
|
135
|
+
void subscription.done.then((how) => {
|
|
136
|
+
live.ending = how;
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await subscription.started;
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
state = { ...state, seeded: false };
|
|
143
|
+
let checked = io.now();
|
|
144
|
+
while (live.ending === null && !io.signal.aborted) {
|
|
145
|
+
try {
|
|
146
|
+
const step = await watchStep(io, socket, state, { read: live.changed, last });
|
|
147
|
+
live.changed = false;
|
|
148
|
+
state = step.state;
|
|
149
|
+
last = step.last;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
live.changed = true;
|
|
153
|
+
}
|
|
154
|
+
if (io.now() - checked >= RENEW_MS) {
|
|
155
|
+
checked = io.now();
|
|
156
|
+
live.changed = true;
|
|
157
|
+
const enabled = await pluginEnabled(socket, place.id).catch(() => true);
|
|
158
|
+
if (!(await renew(place, lease)) || !enabled || (await pluginOff(place))) {
|
|
159
|
+
subscription.stop();
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await io.sleep(TICK_MS, io.signal);
|
|
164
|
+
}
|
|
165
|
+
subscription.stop();
|
|
166
|
+
if (live.ending === 'closed' || live.ending === 'stopped')
|
|
167
|
+
return 0;
|
|
168
|
+
await io.sleep(AGAIN_MS, io.signal);
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
if ((await holder(place))?.id === lease)
|
|
177
|
+
await rm(leasePath(place), { force: true }).catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
}
|