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,301 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { reportThreads } from "../apps/codex-wake.js";
|
|
4
|
+
import { APP_IDS, APP_NAMES } from "../apps/ids.js";
|
|
5
|
+
import { configDir, readText, writeWhole } from "../files.js";
|
|
6
|
+
import { badgesOn, clearHerdr, HERDR_SOURCE, herdrBin, herdrPane, herdrTarget, readPayload, setBadges, TOKEN_NAMES, writeHerdr, } from "../herdr.js";
|
|
7
|
+
import { sessionName } from "../permission.js";
|
|
8
|
+
import { presenceName } from "../presence.js";
|
|
9
|
+
import { shimPath } from "../shim.js";
|
|
10
|
+
import { VERSION } from "../version.js";
|
|
11
|
+
import { badgedPanes, nextWaiting, readWaiting } from "./panes.js";
|
|
12
|
+
import { agentView, CARD_ROW, hasBlock, herdrConfigPath, KEYS_SNIPPET, ownSidebar, pluginOff, pluginPlace, SIDEBAR_BLOCK, saveView, setPluginOff, TOAST_SNIPPET, toastDelivery, viewOn, withBlock, withoutBlock, } from "./plugin.js";
|
|
13
|
+
import { cardsPopup } from "./popup.js";
|
|
14
|
+
import { HerdrError, herdrRequest } from "./socket.js";
|
|
15
|
+
import { pluginEnabled, stopWatcher, watch, watching } from "./watch.js";
|
|
16
|
+
async function toast(place, title, body) {
|
|
17
|
+
if (!place.socket)
|
|
18
|
+
return;
|
|
19
|
+
await herdrRequest(place.socket, 'notification.show', {
|
|
20
|
+
title,
|
|
21
|
+
...(body ? { body } : {}),
|
|
22
|
+
sound: 'none',
|
|
23
|
+
}).catch(() => { });
|
|
24
|
+
}
|
|
25
|
+
async function report(io, command) {
|
|
26
|
+
try {
|
|
27
|
+
const app = command.app ?? 'claude-code';
|
|
28
|
+
const at = command.at ?? io.now();
|
|
29
|
+
if (app !== 'claude-code') {
|
|
30
|
+
await reportThreads(io, app, { session: command.session ?? null, closed: command.closed, at });
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const pane = await herdrTarget(io);
|
|
34
|
+
if (!pane)
|
|
35
|
+
return 0;
|
|
36
|
+
if (command.closed) {
|
|
37
|
+
await clearHerdr(io, at, pane);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const payload = readPayload(await io.readStdin(500).catch(() => ''));
|
|
41
|
+
const name = presenceName(payload?.name) ??
|
|
42
|
+
presenceName(command.agentName) ??
|
|
43
|
+
(await sessionName(command.transcript));
|
|
44
|
+
await writeHerdr(io, {
|
|
45
|
+
app,
|
|
46
|
+
...(name ? { agent: name } : {}),
|
|
47
|
+
...(payload ? { cards: payload.cards } : {}),
|
|
48
|
+
at,
|
|
49
|
+
}, pane);
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
async function next(_io, place) {
|
|
55
|
+
if (!place.socket)
|
|
56
|
+
throw new HerdrError('unreachable', 'Run this from Herdr.');
|
|
57
|
+
const { snapshot, waiting } = await readWaiting(place.socket);
|
|
58
|
+
const target = nextWaiting(waiting, snapshot.focusedPane);
|
|
59
|
+
if (!target) {
|
|
60
|
+
await toast(place, 'Pending You', 'Nothing in Herdr is waiting on you.');
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
await herdrRequest(place.socket, 'pane.focus', { pane_id: target.paneId });
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
async function popup(place, entrypoint) {
|
|
67
|
+
if (!place.socket)
|
|
68
|
+
throw new HerdrError('unreachable', 'Run this from Herdr.');
|
|
69
|
+
try {
|
|
70
|
+
await herdrRequest(place.socket, 'plugin.pane.open', {
|
|
71
|
+
plugin_id: place.id,
|
|
72
|
+
entrypoint,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof HerdrError && error.code === 'ui_busy')
|
|
77
|
+
await toast(place, 'Pending You', 'Close what’s open in Herdr first, then try again.');
|
|
78
|
+
else
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
async function view(place, target) {
|
|
84
|
+
if (!place.socket)
|
|
85
|
+
throw new HerdrError('unreachable', 'Run this from Herdr.');
|
|
86
|
+
const on = target === 'on' ? true : target === 'off' ? false : !(await viewOn(place));
|
|
87
|
+
if (on)
|
|
88
|
+
await herdrRequest(place.socket, 'agent.view.set', agentView(place.id));
|
|
89
|
+
else
|
|
90
|
+
await herdrRequest(place.socket, 'agent.view.clear', { source: `plugin:${place.id}` }).catch(() => { });
|
|
91
|
+
await saveView(place, on);
|
|
92
|
+
await toast(place, 'Pending You', on ? 'Agents waiting on you come first in the Agents panel.' : 'The Agents panel is as it was.');
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
async function clearAll(place, at) {
|
|
96
|
+
if (!place.socket)
|
|
97
|
+
return 0;
|
|
98
|
+
const { snapshot } = await readWaiting(place.socket);
|
|
99
|
+
let cleared = 0;
|
|
100
|
+
for (const pane of badgedPanes(snapshot))
|
|
101
|
+
try {
|
|
102
|
+
await herdrRequest(place.socket, 'pane.report_metadata', {
|
|
103
|
+
pane_id: pane.paneId,
|
|
104
|
+
source: HERDR_SOURCE,
|
|
105
|
+
tokens: Object.fromEntries(TOKEN_NAMES.map((name) => [name, null])),
|
|
106
|
+
clear_state_labels: true,
|
|
107
|
+
seq: at,
|
|
108
|
+
});
|
|
109
|
+
cleared++;
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
return cleared;
|
|
113
|
+
}
|
|
114
|
+
async function reloadConfig(place) {
|
|
115
|
+
if (!place.socket)
|
|
116
|
+
return false;
|
|
117
|
+
return herdrRequest(place.socket, 'server.reload_config').then(() => true, () => false);
|
|
118
|
+
}
|
|
119
|
+
async function unconfigure(io, place) {
|
|
120
|
+
await setBadges(io, false);
|
|
121
|
+
await setPluginOff(place, true);
|
|
122
|
+
const stopped = await stopWatcher(place);
|
|
123
|
+
const cleared = await clearAll(place, io.now()).catch(() => 0);
|
|
124
|
+
if (place.socket)
|
|
125
|
+
await herdrRequest(place.socket, 'agent.view.clear', { source: `plugin:${place.id}` }).catch(() => { });
|
|
126
|
+
await saveView(place, false).catch(() => { });
|
|
127
|
+
const path = herdrConfigPath(io);
|
|
128
|
+
const doc = await readText(path).catch(() => null);
|
|
129
|
+
let removed = false;
|
|
130
|
+
if (doc !== null && hasBlock(doc)) {
|
|
131
|
+
await writeWhole(path, withoutBlock(doc));
|
|
132
|
+
removed = true;
|
|
133
|
+
await reloadConfig(place);
|
|
134
|
+
}
|
|
135
|
+
io.out(`Pending You: ${[
|
|
136
|
+
stopped ? 'stopped its watcher' : 'no watcher was running',
|
|
137
|
+
`took the badges off ${cleared} pane${cleared === 1 ? '' : 's'} and turned them off here`,
|
|
138
|
+
removed ? `took its sidebar row out of ${path}` : 'no sidebar row of its to take out',
|
|
139
|
+
].join('; ')}.\n`);
|
|
140
|
+
await toast(place, 'Pending You is off in Herdr', `Badges, toasts and its sidebar row are gone. Remove the plugin itself with: herdr plugin unlink ${place.id} (or uninstall).`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
const line = (mark, text) => `${mark} ${text}\n`;
|
|
144
|
+
async function setup(io, place, options) {
|
|
145
|
+
const ask = async (question) => {
|
|
146
|
+
if (options.yes)
|
|
147
|
+
return true;
|
|
148
|
+
if (!io.interactive)
|
|
149
|
+
return false;
|
|
150
|
+
return /^y(es)?$/i.test((await io.ask(`${question} (y/N) `)).trim());
|
|
151
|
+
};
|
|
152
|
+
io.out('Pending You for Herdr: setup\n\n');
|
|
153
|
+
await setPluginOff(place, false);
|
|
154
|
+
if (!(await badgesOn(io))) {
|
|
155
|
+
await setBadges(io, true);
|
|
156
|
+
io.out(line('✓', 'Badges are back on: your agents mark their panes when they wait on you.'));
|
|
157
|
+
}
|
|
158
|
+
const path = herdrConfigPath(io);
|
|
159
|
+
const doc = (await readText(path).catch(() => null)) ?? '';
|
|
160
|
+
const delivery = toastDelivery(doc);
|
|
161
|
+
if (delivery === 'off')
|
|
162
|
+
io.out(`${line('!', `Herdr's toasts are off here (${path} doesn't turn them on).`)} To see Pending You's toasts, add this to config.toml on the computer you attach from (here, or the one you run herdr --remote on):\n\n${indent(TOAST_SNIPPET)}\n`);
|
|
163
|
+
else if (delivery === 'unknown')
|
|
164
|
+
io.out(line('·', `Couldn't tell how ${path} delivers toasts; Pending You's follow it.`));
|
|
165
|
+
else
|
|
166
|
+
io.out(line('✓', `Toasts reach you here (delivery = "${delivery}").`));
|
|
167
|
+
if (hasBlock(doc))
|
|
168
|
+
io.out(line('✓', `The sidebar row is in ${path}.`));
|
|
169
|
+
else if (ownSidebar(doc))
|
|
170
|
+
io.out(`${line('·', `${path} sets the agents' sidebar rows itself, so the plugin leaves it alone.`)} Add this row to its rows to show the card each agent waits on you for:\n\n ${CARD_ROW},\n\n`);
|
|
171
|
+
else {
|
|
172
|
+
io.out(`\nHerdr's sidebar can show, under each agent waiting on you, the card it's waiting on. That's this block, added to ${path}:\n\n${indent(SIDEBAR_BLOCK)}\n`);
|
|
173
|
+
if (await ask('Add it?')) {
|
|
174
|
+
const written = await writeBlock(io, place, path, doc);
|
|
175
|
+
io.out(line(written.ok ? '✓' : '!', written.said));
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
io.out(line('·', 'Left config.toml as it was. Run setup again any time to add it.'));
|
|
179
|
+
}
|
|
180
|
+
io.out(`${line('·', "Herdr reads the sidebar's rows and toasts on the computer you attach from: attach from another one (herdr --remote) and they go in its config.toml.")}\n`);
|
|
181
|
+
io.out(`Keys: Herdr leaves your keybindings to you. Add these to config.toml to open your cards with prefix+y and jump to the next pane waiting on you with prefix+shift+y:\n\n${indent(KEYS_SNIPPET)}\n`);
|
|
182
|
+
if (place.socket && !(await viewOn(place))) {
|
|
183
|
+
if (await ask('Show agents waiting on you first in Herdr’s Agents panel?')) {
|
|
184
|
+
await view(place, 'on').catch(() => { });
|
|
185
|
+
io.out(line('✓', 'Agents waiting on you come first in the Agents panel.'));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (place.socket && !(await watching(place)))
|
|
189
|
+
io.background(['herdr', 'watch']);
|
|
190
|
+
if (io.interactive && !options.yes)
|
|
191
|
+
await io.ask('\nPress Enter to close. ');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const indent = (text) => text
|
|
195
|
+
.split('\n')
|
|
196
|
+
.map((each) => (each ? ` ${each}` : each))
|
|
197
|
+
.join('\n');
|
|
198
|
+
async function writeBlock(io, place, path, doc) {
|
|
199
|
+
const before = await readText(path).catch(() => null);
|
|
200
|
+
await writeWhole(path, withBlock(doc));
|
|
201
|
+
const bin = herdrBin(io.env);
|
|
202
|
+
if (bin) {
|
|
203
|
+
const checked = await io.run(bin, ['config', 'check'], 10_000);
|
|
204
|
+
if (checked.code !== 0 && checked.code !== 2) {
|
|
205
|
+
if (before === null)
|
|
206
|
+
await writeWhole(path, '');
|
|
207
|
+
else
|
|
208
|
+
await writeWhole(path, before);
|
|
209
|
+
return { ok: false, said: `Herdr didn't take the block, so ${path} is as it was.` };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const reloaded = await reloadConfig(place);
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
said: reloaded
|
|
216
|
+
? `Added the sidebar row to ${path}; Herdr has it now.`
|
|
217
|
+
: `Added the sidebar row to ${path}. Reload Herdr's config (herdr server reload-config) to see it.`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async function doctor(io, place, options) {
|
|
221
|
+
io.out(`Pending You for Herdr (pendingyou ${VERSION})\n\n`);
|
|
222
|
+
const pane = herdrPane(io.env);
|
|
223
|
+
if (!io.env.HERDR_PLUGIN_ID)
|
|
224
|
+
io.out(pane
|
|
225
|
+
? line('✓', `In a Herdr pane (${pane.pane}).`)
|
|
226
|
+
: line('·', 'Not run from a Herdr pane: agents mark their panes when they run in one.'));
|
|
227
|
+
if (await pluginOff(place))
|
|
228
|
+
io.out(line('!', 'The plugin is off here (its unconfigure). Its setup turns it on again.'));
|
|
229
|
+
io.out((await badgesOn(io))
|
|
230
|
+
? line('✓', 'Badges are on: agents in Herdr mark their panes when they wait on you.')
|
|
231
|
+
: line('!', 'Badges are off here (the plugin’s unconfigure). Its setup turns them on again.'));
|
|
232
|
+
const shim = await readFile(shimPath(io), 'utf8').catch(() => null);
|
|
233
|
+
io.out(shim
|
|
234
|
+
? line('✓', `Your agents' hooks run ${shimPath(io)}.`)
|
|
235
|
+
: line('!', 'Pending You isn’t set up for your agents here: run npx -y pendingyou@latest init'));
|
|
236
|
+
const apps = [];
|
|
237
|
+
for (const app of APP_IDS)
|
|
238
|
+
if ((await readText(join(configDir(io), `${app}.json`)).catch(() => null)) !== null)
|
|
239
|
+
apps.push(app);
|
|
240
|
+
if (apps.length)
|
|
241
|
+
io.out(line('✓', `Set up here: ${apps.map((app) => APP_NAMES[app]).join(', ')}.`));
|
|
242
|
+
if (place.socket) {
|
|
243
|
+
try {
|
|
244
|
+
const pong = await herdrRequest(place.socket, 'ping');
|
|
245
|
+
io.out(line('✓', `Herdr ${String(pong?.version ?? '')} answers on its socket.`));
|
|
246
|
+
const enabled = await pluginEnabled(place.socket, place.id).catch(() => false);
|
|
247
|
+
io.out(enabled
|
|
248
|
+
? line('✓', `The plugin (${place.id}) is enabled.`)
|
|
249
|
+
: line('!', `The plugin (${place.id}) isn't linked or is disabled.`));
|
|
250
|
+
const { snapshot, waiting } = await readWaiting(place.socket);
|
|
251
|
+
const badged = badgedPanes(snapshot).length;
|
|
252
|
+
io.out(line('·', `${badged} pane${badged === 1 ? '' : 's'} with Pending You's badges, ${waiting.length} waiting on you.`));
|
|
253
|
+
io.out((await watching(place))
|
|
254
|
+
? line('✓', 'Its watcher is running (toasts when a pane gains a card).')
|
|
255
|
+
: line('!', 'Its watcher isn’t running: it starts with Herdr, or with setup.'));
|
|
256
|
+
io.out(line('·', `Its Agents view is ${(await viewOn(place)) ? 'on' : 'off'}.`));
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
io.out(line('!', error instanceof HerdrError ? error.message : 'Herdr isn’t answering.'));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
io.out(line('·', 'No Herdr socket here: run it from Herdr to check the plugin.'));
|
|
264
|
+
const path = herdrConfigPath(io);
|
|
265
|
+
const doc = await readText(path).catch(() => null);
|
|
266
|
+
const delivery = toastDelivery(doc ?? '');
|
|
267
|
+
io.out(delivery === 'off'
|
|
268
|
+
? line('!', `Toasts are off in ${path}: setup says how to turn them on.`)
|
|
269
|
+
: line('·', `Toasts here: ${delivery}.`));
|
|
270
|
+
io.out(doc !== null && hasBlock(doc)
|
|
271
|
+
? line('✓', `The sidebar row is in ${path}.`)
|
|
272
|
+
: line('·', `No sidebar row of the plugin's in ${path}: setup offers it.`));
|
|
273
|
+
if (options.wait && io.interactive)
|
|
274
|
+
await io.ask('\nPress Enter to close. ');
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
export async function herdr(io, command) {
|
|
278
|
+
const place = pluginPlace(io);
|
|
279
|
+
switch (command.sub) {
|
|
280
|
+
case 'report':
|
|
281
|
+
return report(io, command);
|
|
282
|
+
case 'watch':
|
|
283
|
+
return watch(io, place, { ensure: command.ensure === true });
|
|
284
|
+
case 'event':
|
|
285
|
+
return watch(io, place, { ensure: true }).catch(() => 0);
|
|
286
|
+
case 'next':
|
|
287
|
+
return next(io, place);
|
|
288
|
+
case 'popup':
|
|
289
|
+
return popup(place, command.target ?? 'cards');
|
|
290
|
+
case 'open':
|
|
291
|
+
return cardsPopup(io, place, { origin: command.origin, demo: command.demo === true });
|
|
292
|
+
case 'view':
|
|
293
|
+
return view(place, command.target);
|
|
294
|
+
case 'setup':
|
|
295
|
+
return setup(io, place, { yes: command.yes === true });
|
|
296
|
+
case 'doctor':
|
|
297
|
+
return doctor(io, place, { wait: command.wait === true });
|
|
298
|
+
case 'unconfigure':
|
|
299
|
+
return unconfigure(io, place);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { APP_NAMES } from "../apps/ids.js";
|
|
2
|
+
import { cleanValue } from "../herdr.js";
|
|
3
|
+
export const ASKED_QUIET_MS = 3 * 60_000;
|
|
4
|
+
export const COALESCE_MS = 3000;
|
|
5
|
+
export const TOAST_TITLE_MAX = 80;
|
|
6
|
+
export const TOAST_BODY_MAX = 240;
|
|
7
|
+
export const emptyNotices = () => ({ known: new Map(), pending: [], seeded: false });
|
|
8
|
+
function cardsOf(waiting) {
|
|
9
|
+
const extra = Math.max(0, waiting.waiting - waiting.cards.length);
|
|
10
|
+
return [
|
|
11
|
+
...waiting.cards,
|
|
12
|
+
...Array.from({ length: extra }, (_, index) => `${waiting.paneId}#${waiting.cards.length + index + 1}`),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
export function look(state, waiting, now) {
|
|
16
|
+
const known = new Map();
|
|
17
|
+
const arrived = [];
|
|
18
|
+
for (const pane of waiting) {
|
|
19
|
+
const cards = cardsOf(pane);
|
|
20
|
+
known.set(pane.paneId, { ids: new Set(cards), count: pane.waiting });
|
|
21
|
+
if (!state.seeded)
|
|
22
|
+
continue;
|
|
23
|
+
const before = state.known.get(pane.paneId) ?? { ids: new Set(), count: 0 };
|
|
24
|
+
const gained = pane.waiting - before.count;
|
|
25
|
+
if (gained <= 0)
|
|
26
|
+
continue;
|
|
27
|
+
for (const requestId of cards.filter((id) => !before.ids.has(id)).slice(0, gained))
|
|
28
|
+
arrived.push({
|
|
29
|
+
requestId,
|
|
30
|
+
paneId: pane.paneId,
|
|
31
|
+
seen: now,
|
|
32
|
+
asked: pane.asked.includes(requestId),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const still = state.pending.filter((arrival) => known.get(arrival.paneId)?.ids.has(arrival.requestId));
|
|
36
|
+
return { known, pending: [...still, ...arrived], seeded: true };
|
|
37
|
+
}
|
|
38
|
+
const readyAt = (arrival) => arrival.seen + (arrival.asked ? ASKED_QUIET_MS : 0);
|
|
39
|
+
const who = (pane) => pane.agent ?? (pane.app ? APP_NAMES[pane.app] : 'An agent');
|
|
40
|
+
export function toastFor(arrivals, waiting) {
|
|
41
|
+
const panes = waiting.filter((pane) => arrivals.some((arrival) => arrival.paneId === pane.paneId));
|
|
42
|
+
const first = panes[0];
|
|
43
|
+
if (!first)
|
|
44
|
+
return null;
|
|
45
|
+
const more = first.waiting > 1 ? ` (+${first.waiting - 1} more)` : '';
|
|
46
|
+
const others = panes.length > 1 ? `, and ${panes.length - 1} more pane${panes.length > 2 ? 's' : ''}` : '';
|
|
47
|
+
const title = arrivals.length === 1
|
|
48
|
+
? `Pending You · ${who(first)} is waiting on you`
|
|
49
|
+
: `Pending You · ${arrivals.length} new cards waiting on you`;
|
|
50
|
+
const body = `${first.card ?? 'A new card'}${more} · ${first.workspace} › ${first.tab}${others}`;
|
|
51
|
+
return { title: cleanValue(title, TOAST_TITLE_MAX), body: cleanValue(body, TOAST_BODY_MAX) };
|
|
52
|
+
}
|
|
53
|
+
export function due(state, waiting, now, viewingTab) {
|
|
54
|
+
const ready = state.pending.filter((arrival) => readyAt(arrival) <= now);
|
|
55
|
+
if (ready.length === 0)
|
|
56
|
+
return { toast: null, state };
|
|
57
|
+
const first = Math.min(...ready.map(readyAt));
|
|
58
|
+
if (now < first + COALESCE_MS)
|
|
59
|
+
return { toast: null, state };
|
|
60
|
+
const rest = state.pending.filter((arrival) => !ready.includes(arrival));
|
|
61
|
+
const tabOf = new Map(waiting.map((pane) => [pane.paneId, pane.tabId]));
|
|
62
|
+
const shown = ready.filter((arrival) => tabOf.get(arrival.paneId) !== viewingTab);
|
|
63
|
+
return {
|
|
64
|
+
toast: shown.length ? toastFor(shown, waiting) : null,
|
|
65
|
+
state: { ...state, pending: rest },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { APP_IDS } from "../apps/ids.js";
|
|
2
|
+
import { PRESSING, TOKENS } from "../herdr.js";
|
|
3
|
+
import { herdrRequest } from "./socket.js";
|
|
4
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
const text = (value) => (typeof value === 'string' ? value : null);
|
|
6
|
+
const count = (value) => typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
|
|
7
|
+
const placeOf = (value, key) => {
|
|
8
|
+
const id = text(value[key]);
|
|
9
|
+
if (!id)
|
|
10
|
+
return null;
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
label: text(value.label) ?? id,
|
|
14
|
+
number: count(value.number),
|
|
15
|
+
focused: value.focused === true,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export function readSnapshot(value) {
|
|
19
|
+
const snapshot = isObject(value) ? value : {};
|
|
20
|
+
const list = (key) => (Array.isArray(snapshot[key]) ? snapshot[key] : []).filter(isObject);
|
|
21
|
+
const tokensOf = (value) => Object.fromEntries(Object.entries(isObject(value) ? value : {}).filter((entry) => typeof entry[1] === 'string'));
|
|
22
|
+
return {
|
|
23
|
+
version: text(snapshot.version) ?? '',
|
|
24
|
+
focusedPane: text(snapshot.focused_pane_id),
|
|
25
|
+
focusedTab: text(snapshot.focused_tab_id),
|
|
26
|
+
focusedWorkspace: text(snapshot.focused_workspace_id),
|
|
27
|
+
workspaces: list('workspaces').flatMap((each) => {
|
|
28
|
+
const place = placeOf(each, 'workspace_id');
|
|
29
|
+
return place ? [place] : [];
|
|
30
|
+
}),
|
|
31
|
+
tabs: list('tabs').flatMap((each) => {
|
|
32
|
+
const place = placeOf(each, 'tab_id');
|
|
33
|
+
const workspaceId = text(each.workspace_id);
|
|
34
|
+
return place && workspaceId ? [{ ...place, workspaceId }] : [];
|
|
35
|
+
}),
|
|
36
|
+
panes: list('panes').flatMap((each) => {
|
|
37
|
+
const paneId = text(each.pane_id);
|
|
38
|
+
const workspaceId = text(each.workspace_id);
|
|
39
|
+
const tabId = text(each.tab_id);
|
|
40
|
+
if (!paneId || !workspaceId || !tabId)
|
|
41
|
+
return [];
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
paneId,
|
|
45
|
+
workspaceId,
|
|
46
|
+
tabId,
|
|
47
|
+
focused: each.focused === true,
|
|
48
|
+
agent: text(each.agent),
|
|
49
|
+
status: text(each.agent_status),
|
|
50
|
+
tokens: tokensOf(each.tokens),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const ids = (value) => (value ?? '')
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((each) => each.trim())
|
|
59
|
+
.filter((each) => /^req_[A-Za-z0-9-]{1,40}$/.test(each));
|
|
60
|
+
const pressingRank = (pressing) => pressing ? PRESSING.indexOf(pressing) : PRESSING.length;
|
|
61
|
+
export function waitingIn(snapshot) {
|
|
62
|
+
const workspaces = new Map(snapshot.workspaces.map((each) => [each.id, each]));
|
|
63
|
+
const tabs = new Map(snapshot.tabs.map((each) => [each.id, each]));
|
|
64
|
+
const found = snapshot.panes.flatMap((pane, index) => {
|
|
65
|
+
const waiting = Number.parseInt(pane.tokens[TOKENS.waiting] ?? '', 10);
|
|
66
|
+
if (!Number.isFinite(waiting) || waiting < 1)
|
|
67
|
+
return [];
|
|
68
|
+
const app = pane.tokens[TOKENS.app];
|
|
69
|
+
const pressing = pane.tokens[TOKENS.urgency];
|
|
70
|
+
const workspace = workspaces.get(pane.workspaceId);
|
|
71
|
+
const tab = tabs.get(pane.tabId);
|
|
72
|
+
const entry = {
|
|
73
|
+
paneId: pane.paneId,
|
|
74
|
+
workspaceId: pane.workspaceId,
|
|
75
|
+
tabId: pane.tabId,
|
|
76
|
+
workspace: workspace?.label ?? pane.workspaceId,
|
|
77
|
+
tab: tab?.label ?? pane.tabId,
|
|
78
|
+
app: app && APP_IDS.includes(app) ? app : null,
|
|
79
|
+
agent: pane.tokens[TOKENS.agent] ?? null,
|
|
80
|
+
herdrAgent: pane.agent,
|
|
81
|
+
status: pane.status,
|
|
82
|
+
waiting,
|
|
83
|
+
card: pane.tokens[TOKENS.card] ?? null,
|
|
84
|
+
cards: ids(pane.tokens[TOKENS.cards]),
|
|
85
|
+
pressing: pressing && PRESSING.includes(pressing)
|
|
86
|
+
? pressing
|
|
87
|
+
: null,
|
|
88
|
+
asked: ids(pane.tokens[TOKENS.asked]),
|
|
89
|
+
order: [
|
|
90
|
+
workspace?.number ?? Number.MAX_SAFE_INTEGER,
|
|
91
|
+
tab?.number ?? Number.MAX_SAFE_INTEGER,
|
|
92
|
+
index,
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
return [entry];
|
|
96
|
+
});
|
|
97
|
+
found.sort((a, b) => {
|
|
98
|
+
const first = pressingRank(a.pressing) - pressingRank(b.pressing);
|
|
99
|
+
if (first)
|
|
100
|
+
return first;
|
|
101
|
+
if (a.waiting !== b.waiting)
|
|
102
|
+
return b.waiting - a.waiting;
|
|
103
|
+
for (let index = 0; index < a.order.length; index++)
|
|
104
|
+
if (a.order[index] !== b.order[index])
|
|
105
|
+
return (a.order[index] ?? 0) - (b.order[index] ?? 0);
|
|
106
|
+
return 0;
|
|
107
|
+
});
|
|
108
|
+
return found.map(({ order: _order, ...waiting }) => waiting);
|
|
109
|
+
}
|
|
110
|
+
export async function readWaiting(socket) {
|
|
111
|
+
const result = await herdrRequest(socket, 'session.snapshot');
|
|
112
|
+
const snapshot = readSnapshot(result?.snapshot);
|
|
113
|
+
return { snapshot, waiting: waitingIn(snapshot) };
|
|
114
|
+
}
|
|
115
|
+
export const badgedPanes = (snapshot) => snapshot.panes.filter((pane) => Object.keys(pane.tokens).some((name) => name.startsWith('py_')));
|
|
116
|
+
export function nextWaiting(waiting, focused) {
|
|
117
|
+
if (waiting.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
const at = waiting.findIndex((each) => each.paneId === focused);
|
|
120
|
+
return waiting[(at + 1) % waiting.length] ?? null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { configDir, readJson, writeWhole } from "../files.js";
|
|
4
|
+
import { definedInline, tableSpans, tableValues } from "../toml.js";
|
|
5
|
+
export const PLUGIN_ID = 'pendingyou.herdr';
|
|
6
|
+
export function pluginPlace(io) {
|
|
7
|
+
const id = io.env.HERDR_PLUGIN_ID || PLUGIN_ID;
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
stateDir: io.env.HERDR_PLUGIN_STATE_DIR || join(configDir(io), 'herdr-plugin'),
|
|
11
|
+
socket: io.env.HERDR_SOCKET_PATH || null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function herdrConfigPath(io) {
|
|
15
|
+
if (io.env.HERDR_CONFIG_PATH)
|
|
16
|
+
return io.env.HERDR_CONFIG_PATH;
|
|
17
|
+
return join(io.env.XDG_CONFIG_HOME || join(io.home, '.config'), 'herdr', 'config.toml');
|
|
18
|
+
}
|
|
19
|
+
export const BLOCK_START = '# >>> Pending You for Herdr';
|
|
20
|
+
export const BLOCK_END = '# <<< Pending You for Herdr';
|
|
21
|
+
export const CARD_ROW = '[{ token = "$py_card", fg = "#e0a526" }]';
|
|
22
|
+
export const SIDEBAR_BLOCK = `${BLOCK_START} (added by the plugin's setup; its "unconfigure" takes it out again)
|
|
23
|
+
[ui.sidebar.agents]
|
|
24
|
+
rows = [
|
|
25
|
+
["state_icon", "machine", "workspace", "tab"],
|
|
26
|
+
["agent", "state_text"],
|
|
27
|
+
${CARD_ROW},
|
|
28
|
+
]
|
|
29
|
+
${BLOCK_END}
|
|
30
|
+
`;
|
|
31
|
+
function blockSpan(doc) {
|
|
32
|
+
const start = doc.indexOf(BLOCK_START);
|
|
33
|
+
if (start < 0 || (start > 0 && doc[start - 1] !== '\n'))
|
|
34
|
+
return null;
|
|
35
|
+
const close = doc.indexOf(BLOCK_END, start);
|
|
36
|
+
if (close < 0)
|
|
37
|
+
return null;
|
|
38
|
+
const newline = doc.indexOf('\n', close);
|
|
39
|
+
return { start, end: newline < 0 ? doc.length : newline + 1 };
|
|
40
|
+
}
|
|
41
|
+
export const hasBlock = (doc) => blockSpan(doc) !== null;
|
|
42
|
+
export function ownSidebar(doc) {
|
|
43
|
+
const path = ['ui', 'sidebar', 'agents'];
|
|
44
|
+
const without = withoutBlock(doc);
|
|
45
|
+
return tableSpans(without, path).length > 0 || definedInline(without, path);
|
|
46
|
+
}
|
|
47
|
+
export function withBlock(doc) {
|
|
48
|
+
if (hasBlock(doc))
|
|
49
|
+
return doc;
|
|
50
|
+
const gap = doc === '' ? '' : doc.endsWith('\n\n') ? '' : doc.endsWith('\n') ? '\n' : '\n\n';
|
|
51
|
+
return `${doc}${gap}${SIDEBAR_BLOCK}`;
|
|
52
|
+
}
|
|
53
|
+
export function withoutBlock(doc) {
|
|
54
|
+
const span = blockSpan(doc);
|
|
55
|
+
if (!span)
|
|
56
|
+
return doc;
|
|
57
|
+
let before = doc.slice(0, span.start);
|
|
58
|
+
const after = doc.slice(span.end);
|
|
59
|
+
if (after === '' && before.endsWith('\n\n'))
|
|
60
|
+
before = before.slice(0, -1);
|
|
61
|
+
return before + after;
|
|
62
|
+
}
|
|
63
|
+
export function toastDelivery(doc) {
|
|
64
|
+
if (definedInline(doc, ['ui', 'toast']))
|
|
65
|
+
return 'unknown';
|
|
66
|
+
const delivery = tableValues(doc, ['ui', 'toast']).delivery;
|
|
67
|
+
if (delivery === undefined)
|
|
68
|
+
return 'off';
|
|
69
|
+
return typeof delivery === 'string' ? delivery : 'unknown';
|
|
70
|
+
}
|
|
71
|
+
export const TOAST_SNIPPET = `[ui.toast]
|
|
72
|
+
delivery = "herdr"
|
|
73
|
+
`;
|
|
74
|
+
export const KEYS_SNIPPET = `[[keys.command]]
|
|
75
|
+
key = "prefix+y"
|
|
76
|
+
type = "plugin_action"
|
|
77
|
+
command = "${PLUGIN_ID}.open"
|
|
78
|
+
description = "Pending You: what's waiting on you"
|
|
79
|
+
|
|
80
|
+
[[keys.command]]
|
|
81
|
+
key = "prefix+shift+y"
|
|
82
|
+
type = "plugin_action"
|
|
83
|
+
command = "${PLUGIN_ID}.next"
|
|
84
|
+
description = "Pending You: next pane waiting on you"
|
|
85
|
+
`;
|
|
86
|
+
export function agentView(pluginId) {
|
|
87
|
+
return {
|
|
88
|
+
source: `plugin:${pluginId}`,
|
|
89
|
+
label: 'waiting on you',
|
|
90
|
+
filter: {
|
|
91
|
+
op: 'any',
|
|
92
|
+
filters: [
|
|
93
|
+
{ op: 'exists', field: { token: 'py_waiting' } },
|
|
94
|
+
{ op: 'eq', field: 'status', value: 'blocked' },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
sort: [
|
|
98
|
+
{ field: { token: 'py_urgency' }, order: 'asc' },
|
|
99
|
+
{ field: 'attention', order: 'desc' },
|
|
100
|
+
{ field: 'state_change_seq', order: 'desc' },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const settingsPath = (place) => join(place.stateDir, 'settings.json');
|
|
105
|
+
export async function viewOn(place) {
|
|
106
|
+
const settings = await readJson(settingsPath(place)).catch(() => null);
|
|
107
|
+
return settings?.view === true;
|
|
108
|
+
}
|
|
109
|
+
export async function saveView(place, on) {
|
|
110
|
+
await writeWhole(settingsPath(place), `${JSON.stringify({ version: 1, view: on }, null, 2)}\n`);
|
|
111
|
+
}
|
|
112
|
+
const offPath = (place) => join(place.stateDir, 'off');
|
|
113
|
+
export const pluginOff = (place) => stat(offPath(place)).then(() => true, () => false);
|
|
114
|
+
export async function setPluginOff(place, off) {
|
|
115
|
+
if (off)
|
|
116
|
+
await writeWhole(offPath(place), 'Pending You is off in Herdr: its setup turns it on.\n');
|
|
117
|
+
else
|
|
118
|
+
await rm(offPath(place), { force: true });
|
|
119
|
+
}
|