your-attention 0.1.0 → 0.1.1
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/README.md +2 -0
- package/bin/your-attention.js +6 -2
- package/dist/src/adapters/files.js +66 -0
- package/dist/src/adapters/mdns.js +53 -0
- package/dist/src/adapters/mermaid.js +50 -0
- package/dist/src/adapters/openui-body.js +40 -0
- package/dist/src/adapters/openui-library.js +3 -0
- package/dist/src/adapters/sqlite.js +68 -0
- package/dist/src/cli.js +814 -0
- package/dist/src/client.js +459 -0
- package/dist/src/cloudflare/collector.js +53 -0
- package/dist/src/cloudflare/inbox-object.js +338 -0
- package/dist/src/cloudflare/storage.js +151 -0
- package/dist/src/cloudflare/worker.js +189 -0
- package/dist/src/core/crypto.js +23 -0
- package/dist/src/core/diagrams.js +225 -0
- package/dist/src/core/expr.js +125 -0
- package/dist/src/core/file-links.js +48 -0
- package/dist/src/core/hooks.js +74 -0
- package/dist/src/core/http.js +651 -0
- package/dist/src/core/inbox.js +2463 -0
- package/dist/src/core/pin.js +234 -0
- package/dist/src/core/sealed.js +167 -0
- package/dist/src/core/session-link.js +13 -0
- package/dist/src/core/socket.js +88 -0
- package/dist/src/core/storage.js +102 -0
- package/dist/src/core/summary.js +149 -0
- package/dist/src/core/telemetry.js +221 -0
- package/dist/src/core/types.js +470 -0
- package/dist/src/core/version.js +2 -0
- package/dist/src/core/widgets.js +128 -0
- package/dist/src/extensions/triage.js +232 -0
- package/dist/src/integrations/claude/ask-hook.js +84 -0
- package/dist/src/integrations/claude/channel-flag.js +102 -0
- package/dist/src/integrations/claude/channel.js +276 -0
- package/dist/src/integrations/claude/session-name.js +32 -0
- package/dist/src/integrations/claude/turn-end-hook.js +84 -0
- package/dist/src/integrations/herdr/approve.js +71 -0
- package/dist/src/integrations/herdr/blocked.js +210 -0
- package/dist/src/integrations/herdr/dialog.js +64 -0
- package/dist/src/integrations/herdr/focus.js +31 -0
- package/dist/src/integrations/herdr/place.js +22 -0
- package/dist/src/integrations/images.js +43 -0
- package/dist/src/integrations/opencode/install.js +24 -0
- package/dist/src/integrations/opencode/plugin.js +157 -0
- package/dist/src/integrations/pi/extension.js +166 -0
- package/dist/src/integrations/run-tool.js +157 -0
- package/dist/src/integrations/secrets.js +63 -0
- package/dist/src/integrations/tags.js +27 -0
- package/dist/src/integrations/tools.js +147 -0
- package/dist/src/node/home.js +20 -0
- package/dist/src/node/install.js +225 -0
- package/dist/src/node/links.js +249 -0
- package/dist/src/node/pin-prompt.js +125 -0
- package/dist/src/node/server.js +320 -0
- package/dist/src/node/service.js +74 -0
- package/dist/src/node/telemetry.js +127 -0
- package/dist/src/owner-watch.js +49 -0
- package/dist/src/tui/api.js +203 -0
- package/dist/src/tui/app.js +586 -0
- package/dist/src/tui/body.js +727 -0
- package/dist/src/tui/cardview.js +453 -0
- package/dist/src/tui/doc.js +157 -0
- package/dist/src/tui/edit.js +119 -0
- package/dist/src/tui/input.js +192 -0
- package/dist/src/tui/md.js +196 -0
- package/dist/src/tui/screen.js +225 -0
- package/dist/src/tui/terminal.js +130 -0
- package/dist/src/tui/text.js +103 -0
- package/dist/src/tui/theme.js +105 -0
- package/dist/src/tui/view.js +314 -0
- package/dist/src/watch.js +104 -0
- package/dist/web/age.js +16 -0
- package/dist/web/answers.js +485 -0
- package/dist/web/app.js +739 -0
- package/dist/web/body-faults.js +69 -0
- package/dist/web/components.js +923 -0
- package/dist/web/copy.js +118 -0
- package/dist/web/details.js +59 -0
- package/dist/web/env.js +97 -0
- package/dist/web/favicon-waiting.svg +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +16 -0
- package/dist/web/library-spec.js +33 -0
- package/dist/web/markdown.js +142 -0
- package/dist/web/paste-images.js +193 -0
- package/dist/web/pin-key.js +125 -0
- package/dist/web/pin.js +126 -0
- package/dist/web/queue.js +97 -0
- package/dist/web/sealed.js +13 -0
- package/dist/web/search.js +70 -0
- package/dist/web/stories/cards.stories.js +144 -0
- package/dist/web/stories/page.stories.js +54 -0
- package/dist/web/stories/render.js +16 -0
- package/dist/web/style.css +417 -0
- package/package.json +4 -2
- package/src/core/version.ts +1 -1
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// your-attention <command>. Run it with no command for the list.
|
|
2
|
+
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { Client, describeAnswer, loadConfig, notOwnerAdvice, ownerPairing } from './client.js';
|
|
8
|
+
import { parseTagsFlag } from './integrations/tags.js';
|
|
9
|
+
|
|
10
|
+
import { homeDir, readHome, writePrivate } from './node/home.js';
|
|
11
|
+
import { choosePin, httpPinApi, offerPin, terminal } from './node/pin-prompt.js';
|
|
12
|
+
import { NOTICE, askYesNo, queryUsage, summarize } from './node/telemetry.js';
|
|
13
|
+
import { keepEvent, unackedNotice, watchLine } from './watch.js';
|
|
14
|
+
|
|
15
|
+
const HELP = `your-attention: one inbox for the questions your coding agents ask you.
|
|
16
|
+
|
|
17
|
+
serve [--port 4815] [--host ::] [--no-mdns] run the inbox
|
|
18
|
+
start [options for serve] run the inbox in the background
|
|
19
|
+
stop stop the inbox, or its service when one is installed
|
|
20
|
+
status show the inbox, its files, and this machine's pairings
|
|
21
|
+
open [--link] open the inbox page signed in, or print a one-time sign-in link
|
|
22
|
+
tui [--mono] [--ascii] answer cards full screen in this terminal, with the keyboard and mouse
|
|
23
|
+
clear clear the cards you answered or that closed; search still finds them
|
|
24
|
+
reset --yes remove every card, answer and file; tokens and templates stay
|
|
25
|
+
login-url print a sign-in link that works once, like open --link
|
|
26
|
+
pin status|set|reset|clear read, set, reset or remove the PIN that each browser tab types
|
|
27
|
+
install [--from-source PATH] print the exact commands that connect this machine's harnesses, and ask nothing
|
|
28
|
+
install --help <harness> the full recipe for one harness, even when it is not installed
|
|
29
|
+
discover [--seconds 3] list inboxes on the LAN
|
|
30
|
+
pair <url> [--name NAME] [--role agent|owner] pair this machine with an inbox
|
|
31
|
+
post --agent ID --title TEXT [--text TEXT] [--option LABEL]... [--priority now|soon|later] [--wait]
|
|
32
|
+
post a card, and with --wait print the answer
|
|
33
|
+
ack <card> [<card>...] mark answers read, when a script took them out of band
|
|
34
|
+
watch [--agent ID] [--no-card] [--select FIELD,...] [--type TYPE,...] [--ack]
|
|
35
|
+
print each answer for that agent as a line of JSON; --ack marks each
|
|
36
|
+
read, and without it the card waits in the outbox as unread
|
|
37
|
+
watch --role owner [--agent ID] [--no-card] [--select FIELD,...] [--type TYPE,...]
|
|
38
|
+
print each change to every card, or to that agent's, as the owner
|
|
39
|
+
subscribe --agent ID (--webhook URL | --webhook-file FILE) [--header 'NAME: VALUE']... [--header-file FILE|-]
|
|
40
|
+
send each answer to that agent's cards to an https webhook, once you approve it
|
|
41
|
+
unsubscribe --agent ID stop sending that agent's answers to its webhook
|
|
42
|
+
pi [--tags repo=acme/api,topic=billing] -- [pi args] run pi, and give every card it posts default tags
|
|
43
|
+
(YOUR_ATTENTION_TAGS, read by the pi extension at session start)
|
|
44
|
+
claude-channel the MCP channel server for Claude Code
|
|
45
|
+
claude-hook ask-pre|ask-post|ask-cancel|turn-end the Claude Code hooks: AskUserQuestion, and the Stop request for a card
|
|
46
|
+
herdr-blocked post a card for each herdr agent that is blocked
|
|
47
|
+
opencode-plugin [--write] print or write the OpenCode plugin file that loads the card tools
|
|
48
|
+
triage [--judge-cmd "claude -p" | --judge-url URL --judge-model M --judge-key-env VAR]
|
|
49
|
+
check each card before you see it (OpenRouter by default)
|
|
50
|
+
triage login sign in to OpenRouter for the default judge
|
|
51
|
+
service serve|triage [--write] print or write a launchd or systemd unit
|
|
52
|
+
bridge [--upstream URL] [--port 4815] [--mdns] offer a remote inbox on this machine
|
|
53
|
+
offer ssh <host> [--remote-port 4815] offer this inbox on another machine over SSH
|
|
54
|
+
relay [--port 4900] [--key-env VAR] run a relay that inboxes dial out to
|
|
55
|
+
serve --relay URL [--relay-key-env VAR] also offer this inbox through a relay
|
|
56
|
+
telemetry status|on|off|show|reset-id read or change the anonymous usage counts, off unless you turn them on
|
|
57
|
+
telemetry report [--days 30] [--invites FILE] for the operator: usage per invite, from Analytics Engine
|
|
58
|
+
|
|
59
|
+
Files live in ${homeDir()} (set YOUR_ATTENTION_HOME to move them).`;
|
|
60
|
+
|
|
61
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
62
|
+
|
|
63
|
+
function fail(message ) {
|
|
64
|
+
console.error(message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function client() {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
if (!config) fail('No inbox found. Start one with `your-attention serve`, or pair with `your-attention pair <url>`.');
|
|
71
|
+
return new Client(config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The address this machine's inbox wrote, when server.json holds one. Another program, or a file written halfway,
|
|
75
|
+
* can leave anything there, and every row of the report is a string. */
|
|
76
|
+
function readServerInfo() {
|
|
77
|
+
try {
|
|
78
|
+
const info = JSON.parse(fs.readFileSync(path.join(homeDir(), 'server.json'), 'utf8')) ;
|
|
79
|
+
if (typeof info?.url !== 'string' || !info.url) return undefined;
|
|
80
|
+
return { url: info.url, ...(typeof info.pid === 'number' ? { pid: info.pid } : {}) };
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a your-attention inbox at this address says about itself, from GET /v1/info, or undefined when none answers there.
|
|
87
|
+
* Another program on the address could answer 200 with JSON of its own, so the name has to match and each field has to hold
|
|
88
|
+
* what the report can print. */
|
|
89
|
+
async function infoOf(url ) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`${url}/v1/info`, { signal: AbortSignal.timeout(2000) });
|
|
92
|
+
if (!res.ok) return undefined;
|
|
93
|
+
const info = (await res.json()) ;
|
|
94
|
+
if (info?.name !== 'your-attention') return undefined;
|
|
95
|
+
return {
|
|
96
|
+
name: 'your-attention',
|
|
97
|
+
...(typeof info.id === 'string' ? { id: info.id } : {}),
|
|
98
|
+
...(typeof info.protocol === 'number' ? { protocol: info.protocol } : {}),
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether an inbox answers at this address. */
|
|
106
|
+
const answers = async (url ) => (await infoOf(url)) !== undefined;
|
|
107
|
+
|
|
108
|
+
/** The address of the inbox this machine runs, when one answers there. */
|
|
109
|
+
async function runningUrl() {
|
|
110
|
+
const info = readServerInfo();
|
|
111
|
+
return info && (await answers(info.url)) ? info.url : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/** Read or change the usage setting of the inbox on this machine. */
|
|
117
|
+
async function telemetry(body ) {
|
|
118
|
+
const { url, owner } = localServer();
|
|
119
|
+
const res = await fetch(`${url}/v1/telemetry`, { method: body ? 'PUT' : 'GET', headers: { authorization: `Bearer ${owner}`, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).catch(() => undefined);
|
|
120
|
+
if (!res) fail('No inbox runs on this machine. Start one with `your-attention serve`.');
|
|
121
|
+
if (!res.ok) fail(`The inbox answered ${res.status}: ${((await res.json().catch(() => ({}))) ).error ?? ''}`);
|
|
122
|
+
return (await res.json()) ;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function describeTelemetry(t ) {
|
|
126
|
+
if (t.blocked) return `This machine sends no usage counts: ${t.blocked}.`;
|
|
127
|
+
if (!t.enabled) return `Usage counts are off.\n\n${NOTICE}`;
|
|
128
|
+
return `Usage counts are on. One anonymous report a day goes to ${t.destination}.\nInstall id: ${t.installId}\n\n${NOTICE}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The same setting as one line, for `status`. */
|
|
132
|
+
function telemetryLine(t ) {
|
|
133
|
+
if (t.blocked) return `off: this machine sends none (${t.blocked})`;
|
|
134
|
+
if (!t.enabled) return 'off; turn them on with `your-attention telemetry on`';
|
|
135
|
+
return `on: one anonymous report a day goes to ${t.destination}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The rows of `status`, in two indented columns under a title. */
|
|
139
|
+
function section(title , rows ) {
|
|
140
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
141
|
+
const indent = ' '.repeat(width + 4);
|
|
142
|
+
return [title, ...rows.map(([label, value]) => ` ${label.padEnd(width)} ${value.split('\n').join(`\n${indent}`)}`)].join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The inbox and token agents on this machine use, for `status`, which reports a pairing file it cannot read rather than failing on it. */
|
|
146
|
+
function agentPairing() {
|
|
147
|
+
try {
|
|
148
|
+
const c = loadConfig();
|
|
149
|
+
return typeof c?.url === 'string' && typeof c.token === 'string' ? { url: c.url, token: c.token } : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One answer webhook, for `status`. */
|
|
156
|
+
function describeHook(h ) {
|
|
157
|
+
// The paired address may answer with anything, and the report still prints.
|
|
158
|
+
let host ;
|
|
159
|
+
try { host = new URL(h.url).host; } catch { return 'an entry this report cannot read'; }
|
|
160
|
+
if (!Array.isArray(h.headers) || !h.headers.every((n) => typeof n === 'string')) return 'an entry this report cannot read';
|
|
161
|
+
const parts = [h.status === 'approved' ? `approved, ${h.delivered} sent` : 'waiting for your approval on its card'];
|
|
162
|
+
if (h.headers.length) parts.push(`headers ${h.headers.join(', ')}`);
|
|
163
|
+
if (h.waiting) parts.push(`${h.waiting} waiting to send`);
|
|
164
|
+
if (h.lastError) parts.push(`last failure: ${h.lastError.reason}`);
|
|
165
|
+
return `${host} (${parts.join('; ')})`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A file size in a short unit, for `status`. */
|
|
169
|
+
function bytes(n ) {
|
|
170
|
+
if (n < 1024) return `${n} bytes`;
|
|
171
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
172
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whether a PIN protects the inbox, and any lockout that wrong PINs started. */
|
|
176
|
+
function describePin(pin ) {
|
|
177
|
+
const how = pin.set ? 'set: each new browser tab types it' : pin.neverAsk ? 'none, and the page never asks for one' : 'none; set one with `your-attention pin set`';
|
|
178
|
+
return pin.lockedUntil && pin.lockedUntil > Date.now() ? `${how}\nwrong PINs lock the page until ${new Date(pin.lockedUntil).toLocaleTimeString()}` : how;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A one-time link that signs a browser in, so the owner token stays out of the address bar and the browser history.
|
|
182
|
+
* It signs in to the inbox this machine runs, or else to the inbox this machine paired with, such as a cloud inbox. */
|
|
183
|
+
async function signInLink() {
|
|
184
|
+
const local = runningHere();
|
|
185
|
+
const paired = local ? undefined : ownerPairing();
|
|
186
|
+
const target = local ?? paired ?? fail('No inbox runs on this machine. Start one with `your-attention start`, or pair this machine with your inbox as owner: `your-attention pair <url> --role owner`.');
|
|
187
|
+
const res = await fetch(`${target.url}/v1/sign-in-codes`, { method: 'POST', headers: { authorization: `Bearer ${target.token}`, 'content-type': 'application/json', 'x-your-attention': '1' }, body: '{}' }).catch(() => undefined);
|
|
188
|
+
if (!res) fail(local ? 'No inbox runs on this machine. Start one with `your-attention start`.' : `The inbox this machine paired with, ${target.url}, did not answer.`);
|
|
189
|
+
// An agent token cannot sign a browser in: only the owner's can.
|
|
190
|
+
if (res.status === 403 && !local) fail(notOwnerAdvice(target.url));
|
|
191
|
+
const { code, error } = (await res.json().catch(() => ({}))) ;
|
|
192
|
+
if (!res.ok || !code) fail(`Could not make a sign-in code: ${error ?? res.status}`);
|
|
193
|
+
return `${target.url}/#signin=${encodeURIComponent(code)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The inbox this machine runs, with its owner token, or undefined when it runs none. */
|
|
197
|
+
function runningHere() {
|
|
198
|
+
try {
|
|
199
|
+
const { url } = JSON.parse(fs.readFileSync(path.join(homeDir(), 'server.json'), 'utf8')) ;
|
|
200
|
+
return { url, token: fs.readFileSync(path.join(homeDir(), 'owner-token'), 'utf8').trim() };
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The inbox the PIN commands change: the one this machine runs, or else the one this machine paired with as owner. */
|
|
207
|
+
function pinTarget() {
|
|
208
|
+
return runningHere() ?? ownerPairing() ?? fail('No inbox runs on this machine. Start one with `your-attention serve`, or pair as owner with `your-attention pair <url> --role owner`.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function localServer() {
|
|
212
|
+
try {
|
|
213
|
+
const { url } = JSON.parse(fs.readFileSync(path.join(homeDir(), 'server.json'), 'utf8')) ;
|
|
214
|
+
return { url, owner: fs.readFileSync(path.join(homeDir(), 'owner-token'), 'utf8').trim() };
|
|
215
|
+
} catch {
|
|
216
|
+
fail('No inbox runs on this machine. Start one with `your-attention serve`.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
switch (command) {
|
|
221
|
+
case 'serve': {
|
|
222
|
+
const { values } = parseArgs({ args: rest, options: { port: { type: 'string', default: '4815' }, host: { type: 'string', default: '::' }, 'no-mdns': { type: 'boolean', default: false }, relay: { type: 'string' }, 'relay-key-env': { type: 'string', default: 'YOUR_ATTENTION_RELAY_KEY' } } });
|
|
223
|
+
const relayKey = values.relay ? process.env[String(values['relay-key-env'])] ?? fail(`--relay needs the relay key in $${values['relay-key-env']}`) : '';
|
|
224
|
+
const stop = async () => { await server.close(); process.exit(0); };
|
|
225
|
+
const { serve } = await import('./node/server.js');
|
|
226
|
+
const server = await serve({ port: Number(values.port), host: String(values.host), mdns: !values['no-mdns'], relay: values.relay ? { url: values.relay, key: relayKey } : undefined, onShutdown: () => void stop(), ...(process.stdin.isTTY && process.stderr.isTTY ? { ask: askYesNo } : {}) });
|
|
227
|
+
process.on('SIGINT', stop);
|
|
228
|
+
process.on('SIGTERM', stop);
|
|
229
|
+
// In a terminal, the inbox asks for a PIN until it has one or the person says never. A service has no terminal and never waits here.
|
|
230
|
+
if (process.stdin.isTTY && process.stderr.isTTY) {
|
|
231
|
+
const pin = server.inbox.pin;
|
|
232
|
+
const t = terminal();
|
|
233
|
+
await offerPin(t, { status: async () => pin.status(), set: (p) => pin.set(p), clear: async () => pin.clear(), neverAsk: async () => pin.setNeverAsk(true) });
|
|
234
|
+
t.close();
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'start': {
|
|
239
|
+
// `serve` in the background, with the same options. The shell gets its prompt back once the inbox answers.
|
|
240
|
+
const home = homeDir();
|
|
241
|
+
const already = await runningUrl();
|
|
242
|
+
if (already) { console.log(`The inbox already runs on ${already}.`); break; }
|
|
243
|
+
const log = path.join(home, 'serve.log');
|
|
244
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
245
|
+
const out = fs.openSync(log, 'a', 0o600);
|
|
246
|
+
const from = fs.statSync(log).size;
|
|
247
|
+
const child = spawn(process.execPath, [...process.execArgv, process.argv[1] ?? '', 'serve', ...rest], { detached: true, stdio: ['ignore', out, out], windowsHide: true });
|
|
248
|
+
child.unref();
|
|
249
|
+
const end = Date.now() + 15_000;
|
|
250
|
+
for (;;) {
|
|
251
|
+
const info = readServerInfo();
|
|
252
|
+
if (info && info.pid === child.pid && (await answers(info.url))) {
|
|
253
|
+
console.log(`Started the inbox on ${info.url}. Its log is ${log}. Stop it with: your-attention stop`);
|
|
254
|
+
// The first start in a terminal offers usage counts. They stay off unless the person says yes.
|
|
255
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
256
|
+
const t = await telemetry();
|
|
257
|
+
if (t.notice) { console.log(`\n${NOTICE}`); await telemetry({ enabled: await askYesNo('Send anonymous usage counts?') }); }
|
|
258
|
+
}
|
|
259
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
260
|
+
const t = terminal(process.stdin, process.stdout);
|
|
261
|
+
await offerPin(t, httpPinApi(info.url, localServer().owner));
|
|
262
|
+
t.close();
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
const tail = () => fs.readFileSync(log, 'utf8').slice(from).trim().split('\n').slice(-10).join('\n');
|
|
267
|
+
if (child.exitCode !== null || child.signalCode !== null) fail(`The inbox did not start:\n${tail()}`);
|
|
268
|
+
if (Date.now() > end) fail(`The inbox did not answer within 15 seconds. Its log is ${log}:\n${tail()}`);
|
|
269
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
270
|
+
}
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
case 'stop': {
|
|
274
|
+
// A service restarts the inbox when it exits, so stop the service itself when there is one.
|
|
275
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
276
|
+
const { serviceFile } = await import('./node/service.js');
|
|
277
|
+
const unit = serviceFile('serve', []);
|
|
278
|
+
const sh = (cmd ) => spawnSync('sh', ['-c', cmd], { stdio: 'ignore' }).status === 0;
|
|
279
|
+
if (fs.existsSync(unit.path) && sh(unit.running)) {
|
|
280
|
+
if (!sh(unit.stop)) fail(`The inbox service runs, and \`${unit.stop}\` did not stop it.`);
|
|
281
|
+
console.log(`Stopped the inbox service. It starts again at login, or now with: ${unit.start}`);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const { url, owner } = localServer();
|
|
286
|
+
const res = await fetch(`${url}/v1/shutdown`, { method: 'POST', headers: { authorization: `Bearer ${owner}` } }).catch(() => undefined);
|
|
287
|
+
if (!res) {
|
|
288
|
+
fs.rmSync(path.join(homeDir(), 'server.json'), { force: true });
|
|
289
|
+
fail('No inbox runs on this machine. Start one with `your-attention serve`.');
|
|
290
|
+
}
|
|
291
|
+
if (res.status !== 202) fail(`The inbox on ${url} refused to stop: ${((await res.json().catch(() => ({}))) ).error ?? res.status}`);
|
|
292
|
+
// The inbox stops answering before it closes its database, and removes its address file after.
|
|
293
|
+
const before = readServerInfo()?.pid;
|
|
294
|
+
const end = Date.now() + 10_000;
|
|
295
|
+
while ((await answers(url)) || (before !== undefined && readServerInfo()?.pid === before)) {
|
|
296
|
+
if (Date.now() > end) fail(`The inbox on ${url} is still running after 10 seconds.`);
|
|
297
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
298
|
+
}
|
|
299
|
+
console.log(`Stopped the inbox on ${url}.`);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case 'status': {
|
|
303
|
+
const home = homeDir();
|
|
304
|
+
const serverFile = path.join(home, 'server.json');
|
|
305
|
+
const here = readServerInfo();
|
|
306
|
+
const info = here ? await infoOf(here.url) : undefined;
|
|
307
|
+
const ownerToken = readHome('owner-token', home);
|
|
308
|
+
|
|
309
|
+
// The inbox this machine runs, and what it says about itself.
|
|
310
|
+
const inbox = [];
|
|
311
|
+
if (info && here) {
|
|
312
|
+
inbox.push(['state', 'running']);
|
|
313
|
+
inbox.push(['address', here.url]);
|
|
314
|
+
if (here.pid !== undefined) inbox.push(['pid', String(here.pid)]);
|
|
315
|
+
if (info.id) inbox.push(['inbox id', info.id]);
|
|
316
|
+
if (info.protocol !== undefined) inbox.push(['protocol', String(info.protocol)]);
|
|
317
|
+
if (ownerToken) {
|
|
318
|
+
const pin = await httpPinApi(here.url, ownerToken).status().catch(() => undefined);
|
|
319
|
+
if (pin) inbox.push(['PIN', describePin(pin)]);
|
|
320
|
+
// The usage setting lives in the inbox, and only its owner may read it.
|
|
321
|
+
const usage = await fetch(`${here.url}/v1/telemetry`, { headers: { authorization: `Bearer ${ownerToken}` }, signal: AbortSignal.timeout(2000) })
|
|
322
|
+
.then(async (r) => (r.ok ? (await r.json()) : undefined))
|
|
323
|
+
.catch(() => undefined);
|
|
324
|
+
if (usage) inbox.push(['usage counts', telemetryLine(usage)]);
|
|
325
|
+
}
|
|
326
|
+
} else if (here) {
|
|
327
|
+
inbox.push(['state', `not running; ${serverFile} is left from an earlier run`]);
|
|
328
|
+
inbox.push(['address', here.url]);
|
|
329
|
+
inbox.push(['start it', 'your-attention start']);
|
|
330
|
+
} else if (fs.existsSync(serverFile)) {
|
|
331
|
+
inbox.push(['state', `not running; ${serverFile} holds no address`]);
|
|
332
|
+
inbox.push(['start it', 'your-attention start']);
|
|
333
|
+
} else {
|
|
334
|
+
inbox.push(['state', 'none on this machine']);
|
|
335
|
+
inbox.push(['start it', 'your-attention start, or pair with an inbox: your-attention pair <url>']);
|
|
336
|
+
}
|
|
337
|
+
// A service starts the inbox at login, and `stop` stops the service itself.
|
|
338
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
339
|
+
const { serviceFile } = await import('./node/service.js');
|
|
340
|
+
const unit = serviceFile('serve', []);
|
|
341
|
+
const installed = fs.existsSync(unit.path);
|
|
342
|
+
const running = installed && spawnSync('sh', ['-c', unit.running], { stdio: 'ignore' }).status === 0;
|
|
343
|
+
inbox.push(['service', running ? `running (${unit.path})` : installed ? `installed, not running (${unit.path})` : 'not installed']);
|
|
344
|
+
}
|
|
345
|
+
if (fs.existsSync(path.join(home, 'serve.log'))) inbox.push(['log', path.join(home, 'serve.log')]);
|
|
346
|
+
|
|
347
|
+
// The files this machine keeps, and the secrets they hold.
|
|
348
|
+
const tokens = ['owner-token', 'agent-token', 'extension-token'].filter((f) => fs.existsSync(path.join(home, f)));
|
|
349
|
+
const db = path.join(home, 'inbox.sqlite');
|
|
350
|
+
const files = [['home', home]];
|
|
351
|
+
if (fs.existsSync(db)) files.push(['database', `${db}, ${bytes(fs.statSync(db).size)}`]);
|
|
352
|
+
files.push(['tokens', tokens.length ? tokens.join(', ') : 'none yet']);
|
|
353
|
+
|
|
354
|
+
// The inboxes this machine paired with, and whether each one answers.
|
|
355
|
+
const pairings = [];
|
|
356
|
+
for (const [role, file] of [['agent', 'client.json'], ['owner', 'owner.json']] ) {
|
|
357
|
+
const text = readHome(file, home);
|
|
358
|
+
// No such file means no such pairing. An empty file is there, and the person should see that it is.
|
|
359
|
+
if (text === undefined) continue;
|
|
360
|
+
if (!text) { pairings.push([role, `${file} is empty`]); continue; }
|
|
361
|
+
let pairing ;
|
|
362
|
+
try { pairing = JSON.parse(text); } catch { pairings.push([role, `${file} is unreadable`]); continue; }
|
|
363
|
+
// The file parses to anything at all: `null` and a bare number parse without an error.
|
|
364
|
+
const url = typeof pairing === 'object' && pairing !== null ? (pairing ).url : undefined;
|
|
365
|
+
if (!url || typeof url !== 'string') { pairings.push([role, `${file} holds no address`]); continue; }
|
|
366
|
+
pairings.push([role, `${url} (${file}, ${(await answers(url)) ? 'answering' : 'not answering'})`]);
|
|
367
|
+
}
|
|
368
|
+
if (!pairings.length) pairings.push(['none', 'pair with `your-attention pair <url>`']);
|
|
369
|
+
|
|
370
|
+
// What the agent this machine resolves to was given and never acknowledged. This is the command an agent runs when
|
|
371
|
+
// it wonders whether an answer arrived while it was away.
|
|
372
|
+
// `client.json` can parse to anything at all, and `loadConfig` trusts it: a file holding `null` or `{"url":42}` is a
|
|
373
|
+
// pairing this machine cannot use, and `status` reports that below rather than failing on it.
|
|
374
|
+
const mine = loadConfig();
|
|
375
|
+
const usable = mine && typeof mine.url === 'string' && typeof mine.token === 'string' ? mine : undefined;
|
|
376
|
+
const left = usable ? await new Client(usable).unread().catch(() => undefined) : undefined;
|
|
377
|
+
if (left) {
|
|
378
|
+
const more = left.unread - left.cards.length;
|
|
379
|
+
const line = left.unread
|
|
380
|
+
? `${left.unread}: your-attention ack ${left.cards.join(' ')}${more > 0 ? ` (and ${more} more)` : ''}`
|
|
381
|
+
: 'none';
|
|
382
|
+
console.log(`${section('Answers this machine has not read', [['not read', line]])}\n`);
|
|
383
|
+
}
|
|
384
|
+
// The answer webhooks: every one with the owner token of the inbox here, or the agent pairing's own.
|
|
385
|
+
const hookTarget = info && here && ownerToken ? { url: here.url, token: ownerToken } : agentPairing();
|
|
386
|
+
const hooks = hookTarget
|
|
387
|
+
? await fetch(`${hookTarget.url.replace(/\/$/, '')}/v1/subscriptions`, { headers: { authorization: `Bearer ${hookTarget.token}` }, signal: AbortSignal.timeout(2000) })
|
|
388
|
+
.then(async (r) => (r.ok ? (await r.json()) : undefined)).catch(() => undefined)
|
|
389
|
+
.then((list) => (Array.isArray(list) ? (list ).filter((h) => typeof h === 'object' && h !== null) : undefined))
|
|
390
|
+
: undefined;
|
|
391
|
+
|
|
392
|
+
console.log(section('Inbox on this machine', inbox));
|
|
393
|
+
console.log(`\n${section('Files on this machine', files)}`);
|
|
394
|
+
console.log(`\n${section('Paired inboxes', pairings)}`);
|
|
395
|
+
// The host only, since a webhook address can carry a secret in its path, as the header values do.
|
|
396
|
+
if (hooks) console.log(`\n${section('Answer webhooks', hooks.length ? hooks.map((h) => [String(h.agent ?? 'unknown agent'), describeHook(h)]) : [['none', 'add one with `your-attention subscribe --agent ID --webhook URL`']])}`);
|
|
397
|
+
// Environment variables win over the files, so an agent uses them before its pairing.
|
|
398
|
+
const url = process.env['YOUR_ATTENTION_URL'];
|
|
399
|
+
const token = process.env['YOUR_ATTENTION_TOKEN'];
|
|
400
|
+
if (url || token) {
|
|
401
|
+
const line = url && token ? `${url}, with YOUR_ATTENTION_TOKEN, before the files above` : `${url ? 'YOUR_ATTENTION_URL' : 'YOUR_ATTENTION_TOKEN'} is set alone, so agents ignore it`;
|
|
402
|
+
console.log(`\n${section('Environment', [['agents use', line]])}`);
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'login-url':
|
|
407
|
+
console.log(await signInLink());
|
|
408
|
+
break;
|
|
409
|
+
case 'pin': {
|
|
410
|
+
const [sub = 'status'] = rest;
|
|
411
|
+
const { url, token } = pinTarget();
|
|
412
|
+
const api = httpPinApi(url, token);
|
|
413
|
+
const s = await api.status().catch((e ) => fail(`Could not read the PIN of the inbox on ${url}: ${e.message}`));
|
|
414
|
+
const t = terminal(process.stdin, process.stdout);
|
|
415
|
+
try {
|
|
416
|
+
if (sub === 'status') {
|
|
417
|
+
console.log(s.set ? 'This inbox has a PIN. Each new browser tab types it before it opens the inbox.' : s.neverAsk ? 'This inbox has no PIN, and it does not ask for one. Set one with: your-attention pin set' : 'This inbox has no PIN. Set one with: your-attention pin set');
|
|
418
|
+
if (s.lockedUntil) console.log(`Wrong PINs locked the page until ${new Date(s.lockedUntil).toLocaleTimeString()}. \`your-attention pin reset\` lifts it.`);
|
|
419
|
+
} else if (sub === 'set' || sub === 'reset') {
|
|
420
|
+
if (sub === 'set' && s.set) fail('This inbox has a PIN. Replace it with `your-attention pin reset`, which needs no current PIN.');
|
|
421
|
+
const pin = await choosePin(t);
|
|
422
|
+
if (!pin && sub === 'set') fail('No PIN set.');
|
|
423
|
+
if (pin) await api.set(pin); else await api.clear();
|
|
424
|
+
console.log(pin ? `PIN ${sub === 'set' ? 'set' : 'reset'}. Every open tab types it again.` : 'The PIN is gone, and so is the lockout. The page asks for a new PIN when it opens.');
|
|
425
|
+
} else if (sub === 'clear') {
|
|
426
|
+
await api.clear();
|
|
427
|
+
console.log('The PIN is gone. The inbox page opens without one.');
|
|
428
|
+
} else {
|
|
429
|
+
fail('Usage: your-attention pin status|set|reset|clear');
|
|
430
|
+
}
|
|
431
|
+
} catch (e) {
|
|
432
|
+
fail(`The inbox refused: ${(e ).message}`);
|
|
433
|
+
} finally {
|
|
434
|
+
t.close();
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case 'clear':
|
|
439
|
+
case 'reset': {
|
|
440
|
+
if (command === 'reset' && !rest.includes('--yes')) fail('reset removes every card, answer and file, and cannot be undone. Run `your-attention reset --yes` to confirm, or `your-attention clear` to clear only the cards you dealt with.');
|
|
441
|
+
const { url, owner } = localServer();
|
|
442
|
+
const res = await fetch(`${url}/v1/${command === 'clear' ? 'archive' : 'reset'}`, { method: 'POST', headers: { authorization: `Bearer ${owner}`, 'content-type': 'application/json' }, body: JSON.stringify(command === 'reset' ? { confirm: 'reset' } : {}) }).catch(() => undefined);
|
|
443
|
+
if (!res) fail('No inbox runs on this machine. Start one with `your-attention start`.');
|
|
444
|
+
const body = (await res.json().catch(() => ({}))) ;
|
|
445
|
+
if (!res.ok) fail(`The inbox refused: ${body.error ?? res.status}`);
|
|
446
|
+
console.log(command === 'clear' ? `Cleared ${body.archived} cards. Search still finds them.` : `Removed ${body.removed} cards and their answers.`);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case 'open': {
|
|
450
|
+
const { values } = parseArgs({ args: rest, options: { link: { type: 'boolean', default: false } } });
|
|
451
|
+
const link = await signInLink();
|
|
452
|
+
if (values.link) {
|
|
453
|
+
console.log(`Open this link in the browser to sign in. It works once, within 5 minutes:\n${link}`);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
457
|
+
execFile(opener, [link]);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case 'tui': {
|
|
461
|
+
const { values } = parseArgs({ args: rest, options: { mono: { type: 'boolean', default: false }, ascii: { type: 'boolean', default: false } } });
|
|
462
|
+
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !process.env['YOUR_ATTENTION_TUI_SIZE']) fail('your-attention tui needs a terminal for its input and its output.');
|
|
463
|
+
const { runTui } = await import('./tui/app.js');
|
|
464
|
+
process.exit(await runTui({ env: process.env, io: { input: process.stdin, output: process.stdout }, mono: values.mono, ascii: values.ascii }));
|
|
465
|
+
}
|
|
466
|
+
case 'install': {
|
|
467
|
+
// An agent reads this, so nothing here prompts: every line it needs to run comes out as one command.
|
|
468
|
+
// `--help` takes a harness name. On its own it means "show me the report", and parseArgs would read the next
|
|
469
|
+
// flag as that missing value, so a `--help` followed by nothing or by another flag is answered before parsing.
|
|
470
|
+
const helpAt = rest.indexOf('--help');
|
|
471
|
+
const next = helpAt < 0 ? undefined : rest[helpAt + 1];
|
|
472
|
+
const bareHelp = helpAt >= 0 && (next === undefined || next.startsWith('-'));
|
|
473
|
+
let values ;
|
|
474
|
+
try {
|
|
475
|
+
({ values } = parseArgs({
|
|
476
|
+
args: bareHelp ? rest.filter((a) => a !== '--help') : rest,
|
|
477
|
+
options: { help: { type: 'string' }, 'from-source': { type: 'string' } },
|
|
478
|
+
allowPositionals: true,
|
|
479
|
+
}));
|
|
480
|
+
} catch (e) {
|
|
481
|
+
// An option without its value is a usage mistake, and a reader of this command is an agent: one clear line.
|
|
482
|
+
fail(`install: ${(e ).message}. Usage: your-attention install [--from-source PATH], or install --help <harness>.`);
|
|
483
|
+
}
|
|
484
|
+
const { HARNESSES, detect, installHelp, installReport, which } = await import('./node/install.js');
|
|
485
|
+
const pathEnv = process.env['PATH'];
|
|
486
|
+
const facts = {
|
|
487
|
+
found: detect(pathEnv),
|
|
488
|
+
brew: which('brew', pathEnv) !== undefined,
|
|
489
|
+
self: which('your-attention', pathEnv),
|
|
490
|
+
...(values['from-source'] ? { fromSource: values['from-source'] } : {}),
|
|
491
|
+
};
|
|
492
|
+
if (values.help !== undefined) {
|
|
493
|
+
const lines = installHelp(values.help, facts);
|
|
494
|
+
if (!lines) fail(`Unknown harness: ${values.help}. Known ones: ${HARNESSES.map((h) => h.name).join(', ')}.`);
|
|
495
|
+
console.log(lines.join('\n'));
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
console.log(installReport(facts).join('\n'));
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
case 'discover': {
|
|
502
|
+
const { values } = parseArgs({ args: rest, options: { seconds: { type: 'string', default: '3' } } });
|
|
503
|
+
const { discover } = await import('./adapters/mdns.js');
|
|
504
|
+
const found = await discover(Number(values.seconds) * 1000);
|
|
505
|
+
if (!found.length) console.log('No inbox found on this network.');
|
|
506
|
+
for (const f of found) console.log(`${f.name}\thttp://${f.addresses.find((a) => !a.includes(':')) ?? f.host}:${f.port}\tid=${f.txt['id'] ?? '?'}`);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case 'pair': {
|
|
510
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { name: { type: 'string', default: `${os.userInfo().username}@${os.hostname()}` }, role: { type: 'string', default: 'agent' } } });
|
|
511
|
+
const url = (positionals[0] ?? fail('Usage: your-attention pair <url>')).replace(/\/$/, '');
|
|
512
|
+
const res = await fetch(`${url}/v1/pair`, { method: 'POST', body: JSON.stringify({ name: values.name, role: values.role }) });
|
|
513
|
+
const req = (await res.json()) ;
|
|
514
|
+
if (!res.ok || !req.id) fail(`Pairing refused: ${req.error ?? res.status}`);
|
|
515
|
+
console.log(`Pairing code: ${req.code}. Approve the card with this code in your inbox.`);
|
|
516
|
+
for (;;) {
|
|
517
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
518
|
+
const s = (await (await fetch(`${url}/v1/pair/${req.id}`)).json()) ;
|
|
519
|
+
if (s.status === 'denied') fail('The pairing was denied.');
|
|
520
|
+
if (s.token) {
|
|
521
|
+
const info = (await (await fetch(`${url}/v1/info`)).json()) ;
|
|
522
|
+
// An owner pairing gets a file of its own, so it never replaces the agent pairing the agents on this machine use.
|
|
523
|
+
const file = path.join(homeDir(), values.role === 'owner' ? 'owner.json' : 'client.json');
|
|
524
|
+
writePrivate(file, JSON.stringify({ url, token: s.token, id: info.id }));
|
|
525
|
+
console.log(`Paired. The token is in ${file}.`);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
case 'post': {
|
|
532
|
+
const { values } = parseArgs({ args: rest, options: { agent: { type: 'string' }, title: { type: 'string' }, text: { type: 'string' }, option: { type: 'string', multiple: true }, priority: { type: 'string', default: 'soon' }, wait: { type: 'boolean', default: false } } });
|
|
533
|
+
if (!values.agent || !values.title) fail('Usage: your-attention post --agent ID --title TEXT');
|
|
534
|
+
const c = client();
|
|
535
|
+
const options = values.option?.map((label, i) => ({ id: `o${i + 1}`, label }));
|
|
536
|
+
const card = await c.post({ agent: values.agent, title: values.title, text: values.text, options, priority: values.priority , origin: { harness: 'cli', host: os.hostname(), cwd: process.cwd() } });
|
|
537
|
+
console.log(card.id);
|
|
538
|
+
if (!values.wait) break;
|
|
539
|
+
const stop = new AbortController();
|
|
540
|
+
await c.stream(values.agent, async (ev) => {
|
|
541
|
+
if (ev.type !== 'answer' || ev.card.id !== card.id) return;
|
|
542
|
+
console.log(describeAnswer(ev.card, ev.answer));
|
|
543
|
+
await c.ack(card.id);
|
|
544
|
+
stop.abort();
|
|
545
|
+
}, stop.signal);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'ack': {
|
|
549
|
+
// A script that takes an answer out of band, as one reading `watch` does, marks it read with this. Without the ack
|
|
550
|
+
// the card waits in the page's outbox, and the person cannot tell what the agent consumed. It takes every id it is
|
|
551
|
+
// given, because the watcher's own advice names them all on one line.
|
|
552
|
+
const ids = rest.filter((a) => !a.startsWith('-'));
|
|
553
|
+
if (!ids.length) fail('Usage: your-attention ack <card> [<card>...]');
|
|
554
|
+
const c = client();
|
|
555
|
+
for (const id of ids) {
|
|
556
|
+
const answer = await c.ack(id).catch((e ) => fail(`The inbox refused ${id}: ${e.message}`));
|
|
557
|
+
if (!answer.readAt) fail(`The inbox did not mark ${id} read`);
|
|
558
|
+
console.log(`Card ${id} is read.`);
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case 'watch': {
|
|
563
|
+
const { values } = parseArgs({ args: rest, options: {
|
|
564
|
+
role: { type: 'string', default: 'agent' },
|
|
565
|
+
agent: { type: 'string' },
|
|
566
|
+
type: { type: 'string' },
|
|
567
|
+
'no-card': { type: 'boolean', default: false },
|
|
568
|
+
select: { type: 'string' },
|
|
569
|
+
ack: { type: 'boolean', default: false },
|
|
570
|
+
} });
|
|
571
|
+
if (values.role !== 'agent' && values.role !== 'owner') fail(`--role is agent or owner, not ${values.role}.`);
|
|
572
|
+
const owner = values.role === 'owner';
|
|
573
|
+
if (values['no-card'] && values.select !== undefined) fail('Choose --no-card or --select, not both: one leaves the card out, and the other picks the fields to keep.');
|
|
574
|
+
const fields = values.select?.split(',').map((f) => f.trim()).filter(Boolean);
|
|
575
|
+
if (fields?.length === 0) fail('--select takes a comma-separated list of card fields, such as --select=id,title. Without it, each line carries the whole card.');
|
|
576
|
+
const types = values.type?.split(',').map((f) => f.trim()).filter(Boolean);
|
|
577
|
+
if (types?.length === 0) fail(`--type takes a comma-separated list of event types, such as --type=${owner ? 'change,presence' : 'answer,returned'}. Without it, the watcher prints every type.`);
|
|
578
|
+
// Only the token that posted a card may acknowledge its answer, so an owner watcher has nothing it could mark read.
|
|
579
|
+
if (owner && values.ack) fail('--ack marks an answer read for the agent that posted the card, and only that agent\'s token may. An owner watcher can\'t: drop --ack, or watch as the agent with --agent ID.');
|
|
580
|
+
const stop = new AbortController();
|
|
581
|
+
// What a watcher cannot see from its side: printing an answer does not acknowledge it, and its card waits in the
|
|
582
|
+
// person's outbox as unread until something does. Say so when the watch ends, rather than leaving it silent.
|
|
583
|
+
const unacked = [];
|
|
584
|
+
let said = false;
|
|
585
|
+
const say = () => {
|
|
586
|
+
if (said || values.ack || !unacked.length) return;
|
|
587
|
+
said = true;
|
|
588
|
+
// The ids come from the event, not from the printed line: `--no-card` and `--select` leave the card out of that
|
|
589
|
+
// line, so advice naming `<card>` would be advice the reader cannot follow.
|
|
590
|
+
console.error(unackedNotice(unacked));
|
|
591
|
+
};
|
|
592
|
+
const end = () => {
|
|
593
|
+
say();
|
|
594
|
+
stop.abort();
|
|
595
|
+
// Closing a socket waits for the far end's answer, which a quiet inbox may never send, and the watch has nothing
|
|
596
|
+
// left to write. So the signal ends the watch on its own, and a stream that returns first ends it sooner.
|
|
597
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
598
|
+
};
|
|
599
|
+
for (const signal of ['SIGINT', 'SIGTERM'] ) process.on(signal, end);
|
|
600
|
+
// A reader that stops reading, as `watch | head -1` does, ends the watch: it is not an error.
|
|
601
|
+
process.stdout.on('error', (e ) => {
|
|
602
|
+
if (e.code !== 'EPIPE') return;
|
|
603
|
+
say();
|
|
604
|
+
process.exit(0);
|
|
605
|
+
});
|
|
606
|
+
const options = { noCard: values['no-card'], fields };
|
|
607
|
+
if (owner) {
|
|
608
|
+
// The owner token of the inbox this machine runs, or else the owner pairing, as for `login-url`.
|
|
609
|
+
const { checkOwner, watchOwner } = await import('./owner-watch.js');
|
|
610
|
+
const { ApiError } = await import('./tui/api.js');
|
|
611
|
+
const target = runningHere() ?? ownerPairing() ?? fail('This machine runs no inbox and is not paired with one as owner. Pair it as owner with `your-attention pair <url> --role owner`, or start an inbox with `your-attention start`.');
|
|
612
|
+
await checkOwner(target).catch((e ) => {
|
|
613
|
+
if (e instanceof ApiError && e.status === 403) fail(notOwnerAdvice(target.url));
|
|
614
|
+
if (e instanceof ApiError && e.status === 401) fail(`The inbox at ${target.url} refused this machine's owner token. Pair it again with \`your-attention pair ${target.url} --role owner\`.`);
|
|
615
|
+
fail(`The inbox at ${target.url} did not answer: ${(e ).message}`);
|
|
616
|
+
});
|
|
617
|
+
console.error(`Watching ${target.url} as the owner${values.agent ? ` for the cards of ${values.agent}` : ', for every card'}, one line of JSON each.`);
|
|
618
|
+
await watchOwner(target, { options, filter: { types, agent: values.agent }, write: (line) => process.stdout.write(`${JSON.stringify(line)}\n`), signal: stop.signal });
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
const c = client();
|
|
622
|
+
const agent = values.agent ?? 'cli:watch';
|
|
623
|
+
console.error(`Watching ${c.url} for the answers to cards posted as ${agent}, one line of JSON each.`);
|
|
624
|
+
await c.stream(agent, async (ev) => {
|
|
625
|
+
if (!keepEvent(ev, { types })) return;
|
|
626
|
+
process.stdout.write(`${JSON.stringify(watchLine(ev, options, Date.now()))}\n`);
|
|
627
|
+
// Only when asked: a watcher that acknowledged every answer would mark read what the agent waiting for it has not seen.
|
|
628
|
+
if (ev.type !== 'answer') return;
|
|
629
|
+
if (!values.ack) {
|
|
630
|
+
unacked.push(ev.card.id);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
await c.ack(ev.card.id).catch((e ) => console.error(`Could not mark card ${ev.card.id} read: ${e.message}`));
|
|
634
|
+
}, stop.signal);
|
|
635
|
+
say();
|
|
636
|
+
// The notice above covers what this process printed. A watcher that was down, or that started after an answer
|
|
637
|
+
// landed, learns the rest from the inbox: the count, and the cards to acknowledge.
|
|
638
|
+
// Every run asks, `--ack` or not: with `--ack` the stream acks what it prints, so anything still unread is an answer
|
|
639
|
+
// that arrived while nothing was listening, which is the case this line exists for.
|
|
640
|
+
const left = await c.unread(agent).catch(() => undefined);
|
|
641
|
+
if (left && left.unread > 0) console.error(`The inbox holds ${left.unread} unread answer${left.unread === 1 ? '' : 's'} for ${agent}: your-attention ack ${left.cards.join(' ')}${left.unread > left.cards.length ? ` (and ${left.unread - left.cards.length} more)` : ''}`);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
case 'subscribe': {
|
|
645
|
+
const { values } = parseArgs({ args: rest, options: { agent: { type: 'string' }, webhook: { type: 'string' }, 'webhook-file': { type: 'string' }, header: { type: 'string', multiple: true }, 'header-file': { type: 'string' } } });
|
|
646
|
+
const usage = "Usage: your-attention subscribe --agent ID (--webhook URL | --webhook-file FILE) [--header 'NAME: VALUE']... [--header-file FILE|-]";
|
|
647
|
+
if (!values.agent || (values.webhook === undefined) === (values['webhook-file'] === undefined)) fail(usage);
|
|
648
|
+
// A secret goes in a file or on stdin: an argument shows in `ps` to every user of this machine, and in the shell history.
|
|
649
|
+
const readText = (file , what ) => {
|
|
650
|
+
try { return file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); } catch (e) { return fail(`Could not read the ${what} in ${file === '-' ? 'stdin' : file}: ${(e ).code ?? (e ).message}.`); }
|
|
651
|
+
};
|
|
652
|
+
const webhook = values.webhook ?? readText(values['webhook-file'] ?? '', 'webhook address').trim();
|
|
653
|
+
const file = values['header-file'];
|
|
654
|
+
const lines = file === undefined ? [] : readText(file, 'headers').split(/\r?\n/).filter((l) => l.trim());
|
|
655
|
+
const headers = {};
|
|
656
|
+
for (const [i, h] of [...(values.header ?? []), ...lines].entries()) {
|
|
657
|
+
const at = h.indexOf(':');
|
|
658
|
+
// The line may be a bare token, so the message never quotes it.
|
|
659
|
+
if (at < 0 || !h.slice(0, at).trim()) fail(`Header ${i + 1} has no name: each --header, and each line of --header-file, takes 'NAME: VALUE', such as 'Authorization: Bearer <token>'.`);
|
|
660
|
+
headers[h.slice(0, at).trim()] = h.slice(at + 1).trim();
|
|
661
|
+
}
|
|
662
|
+
const r = await client().subscribe(values.agent, { url: webhook, headers }).catch((e ) => fail(`The inbox refused the webhook: ${e.message}`));
|
|
663
|
+
console.error(`Asked the inbox to send the answers for ${values.agent} to ${new URL(r.subscription.url).host}. Nothing goes out until you approve the card "${r.card.title}" in the inbox.`);
|
|
664
|
+
console.error('Each call carries x-your-attention-signature, an HMAC-SHA256 of the body keyed with the secret below. The inbox shows it only this once.');
|
|
665
|
+
console.log(JSON.stringify({ id: r.subscription.id, status: r.subscription.status, card: r.card.id, secret: r.secret }));
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case 'unsubscribe': {
|
|
669
|
+
const { values } = parseArgs({ args: rest, options: { agent: { type: 'string' } } });
|
|
670
|
+
if (!values.agent) fail('Usage: your-attention unsubscribe --agent ID');
|
|
671
|
+
const { removed } = await client().unsubscribe(values.agent).catch((e ) => fail(`The inbox refused: ${e.message}`));
|
|
672
|
+
console.log(removed ? `Stopped sending the answers for ${values.agent} to its webhook.` : `${values.agent} had no webhook for this token.`);
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
case 'triage': {
|
|
676
|
+
const triage = await import('./extensions/triage.js');
|
|
677
|
+
if (rest[0] === 'login') {
|
|
678
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
679
|
+
await triage.openRouterLogin((u) => { console.log(`Opening ${u}`); execFile(opener, [u]); });
|
|
680
|
+
console.log(`Signed in. The key is in ${triage.OPENROUTER_KEY_FILE()}.`);
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
const { values } = parseArgs({ args: rest, options: { 'judge-cmd': { type: 'string' }, 'judge-url': { type: 'string' }, 'judge-model': { type: 'string' }, 'judge-key-env': { type: 'string' }, name: { type: 'string', default: 'triage' } } });
|
|
684
|
+
const { loadExtensionConfig } = await import('./client.js');
|
|
685
|
+
const config = loadExtensionConfig() ?? fail('No inbox found: start one, or set YOUR_ATTENTION_URL and YOUR_ATTENTION_TOKEN to an extension token.');
|
|
686
|
+
const judge = values['judge-cmd'] ? triage.commandJudge(values['judge-cmd'])
|
|
687
|
+
: values['judge-url'] ? triage.chatJudge({ baseUrl: values['judge-url'], model: values['judge-model'] ?? fail('--judge-model is required with --judge-url'), apiKey: process.env[values['judge-key-env'] ?? ''] ?? '' })
|
|
688
|
+
: triage.defaultJudge(values['judge-model']);
|
|
689
|
+
const stop = new AbortController();
|
|
690
|
+
process.on('SIGTERM', () => stop.abort());
|
|
691
|
+
process.on('SIGINT', () => stop.abort());
|
|
692
|
+
await triage.runTriage({ ...config, judge, name: values.name }, stop.signal);
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case 'service': {
|
|
696
|
+
// The options after the name go to the service as they were typed, without --write.
|
|
697
|
+
const [name, ...given] = rest;
|
|
698
|
+
const write = given.includes('--write');
|
|
699
|
+
const args = given.filter((a) => a !== '--write');
|
|
700
|
+
if (name !== 'serve' && name !== 'triage') fail('Usage: your-attention service serve|triage [--write] [options for the command]');
|
|
701
|
+
const { serviceFile } = await import('./node/service.js');
|
|
702
|
+
const unit = serviceFile(name, [name, ...args]);
|
|
703
|
+
if (!write) { console.log(`# ${unit.path}\n${unit.content}\n# then: ${unit.start}`); break; }
|
|
704
|
+
fs.mkdirSync(path.dirname(unit.path), { recursive: true });
|
|
705
|
+
fs.writeFileSync(unit.path, unit.content);
|
|
706
|
+
console.log(`Wrote ${unit.path}. Start it with: ${unit.start}`);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
case 'bridge': {
|
|
710
|
+
const { values } = parseArgs({ args: rest, options: { upstream: { type: 'string' }, port: { type: 'string', default: '4815' }, host: { type: 'string', default: '127.0.0.1' }, mdns: { type: 'boolean', default: false } } });
|
|
711
|
+
const upstream = values.upstream ?? loadConfig()?.url ?? ownerPairing()?.url ?? fail('Give the inbox with --upstream <url>, or pair this machine first.');
|
|
712
|
+
const b = await (await import('./node/links.js')).bridge({ upstream, port: Number(values.port), host: values.host, mdns: values.mdns });
|
|
713
|
+
console.error(`[bridge] ${b.url} leads to ${upstream}`);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'offer': {
|
|
717
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { 'remote-port': { type: 'string', default: '4815' } } });
|
|
718
|
+
if (positionals[0] !== 'ssh' || !positionals[1]) fail('Usage: your-attention offer ssh <host> [--remote-port 4815]');
|
|
719
|
+
const { url } = localServer();
|
|
720
|
+
(await import('./node/links.js')).offerOverSsh({ host: positionals[1], localPort: Number(new URL(url).port), remotePort: Number(values['remote-port']) });
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
case 'relay': {
|
|
724
|
+
const { values } = parseArgs({ args: rest, options: { port: { type: 'string', default: '4900' }, host: { type: 'string', default: '0.0.0.0' }, 'key-env': { type: 'string', default: 'YOUR_ATTENTION_RELAY_KEY' } } });
|
|
725
|
+
const key = process.env[String(values['key-env'])] ?? fail(`The relay needs its key in $${values['key-env']}.`);
|
|
726
|
+
const r = await (await import('./node/links.js')).relayServer({ port: Number(values.port), host: values.host, key });
|
|
727
|
+
console.error(`[relay] listening on ${r.url}; inboxes are at /i/<inbox id>/`);
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case 'pi': {
|
|
731
|
+
// pi rejects a flag it does not know, such as --tags, so this launcher takes the flag and hands pi the rest.
|
|
732
|
+
const sep = rest.indexOf('--');
|
|
733
|
+
const own = sep === -1 ? rest : rest.slice(0, sep);
|
|
734
|
+
const piArgs = sep === -1 ? [] : rest.slice(sep + 1);
|
|
735
|
+
const { values } = parseArgs({ args: own, options: { tags: { type: 'string' } } });
|
|
736
|
+
const env = { ...process.env };
|
|
737
|
+
if (values.tags !== undefined) {
|
|
738
|
+
parseTagsFlag(values.tags);
|
|
739
|
+
env['YOUR_ATTENTION_TAGS'] = values.tags;
|
|
740
|
+
}
|
|
741
|
+
// pi draws in the terminal, so it inherits this one's input, output and errors, and it gets every argument after
|
|
742
|
+
// -- unchanged.
|
|
743
|
+
// Windows runs a `.cmd` only through the shell it belongs to.
|
|
744
|
+
const win = process.platform === 'win32';
|
|
745
|
+
const child = win
|
|
746
|
+
? spawnSync(process.env['COMSPEC'] ?? 'cmd.exe', ['/d', '/s', '/c', 'pi', ...piArgs], { stdio: 'inherit', env })
|
|
747
|
+
: spawnSync('pi', piArgs, { stdio: 'inherit', env });
|
|
748
|
+
if (child.error) fail(`Could not run pi: ${child.error.message}`);
|
|
749
|
+
process.exit(child.status ?? 1);
|
|
750
|
+
}
|
|
751
|
+
case 'claude-channel':
|
|
752
|
+
await import('./integrations/claude/channel.js');
|
|
753
|
+
break;
|
|
754
|
+
case 'claude-hook':
|
|
755
|
+
if (rest[0] === 'turn-end') await (await import('./integrations/claude/turn-end-hook.js')).main();
|
|
756
|
+
else await (await import('./integrations/claude/ask-hook.js')).main(rest[0] ?? '');
|
|
757
|
+
break;
|
|
758
|
+
case 'opencode-plugin': {
|
|
759
|
+
const { values } = parseArgs({ args: rest, options: { write: { type: 'boolean', default: false } } });
|
|
760
|
+
const { pluginFile, pluginSource } = await import('./integrations/opencode/install.js');
|
|
761
|
+
const file = pluginFile();
|
|
762
|
+
if (!values.write) { console.log(`# ${file}\n${pluginSource()}`); break; }
|
|
763
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
764
|
+
fs.writeFileSync(file, pluginSource());
|
|
765
|
+
console.log(`Wrote ${file}. OpenCode loads it at its next start.`);
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
case 'herdr-blocked':
|
|
769
|
+
await (await import('./integrations/herdr/blocked.js')).main();
|
|
770
|
+
break;
|
|
771
|
+
case 'telemetry': {
|
|
772
|
+
const [sub, ...args] = rest;
|
|
773
|
+
if (sub === 'report') {
|
|
774
|
+
// For the operator of the hosted service. Invite codes stay in the file: the report prints the label beside each one.
|
|
775
|
+
const { values } = parseArgs({ args, options: { days: { type: 'string', default: '30' }, invites: { type: 'string' } } });
|
|
776
|
+
const account = process.env['CLOUDFLARE_ACCOUNT_ID'];
|
|
777
|
+
const token = process.env['CLOUDFLARE_API_TOKEN'];
|
|
778
|
+
if (!account || !token) fail('Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, a token with Account Analytics Read.');
|
|
779
|
+
const labels = new Map ();
|
|
780
|
+
if (values.invites) {
|
|
781
|
+
const { inviteId } = await import('./core/telemetry.js');
|
|
782
|
+
const lines = fs.readFileSync(values.invites, 'utf8').split(/\r?\n/);
|
|
783
|
+
for (const [i, line] of lines.entries()) {
|
|
784
|
+
const [code, ...label] = line.trim().split(/\s+/);
|
|
785
|
+
if (code && !code.startsWith('#')) labels.set(await inviteId(code), label.join(' ') || `line ${i + 1}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const rows = summarize(await queryUsage(account, token, Number(values.days)), labels);
|
|
789
|
+
if (!rows.length) { console.log(`No usage reports in the last ${values.days} days.`); break; }
|
|
790
|
+
const cols = ['invite', 'inboxes', 'days', 'cards', 'answers', 'pairings', 'pages', 'peakAgents', 'peakInstances', 'peakTokens', 'flag'] ;
|
|
791
|
+
const table = [cols.map(String), ...rows.map((r) => cols.map((c) => String(r[c])))];
|
|
792
|
+
const widths = cols.map((_, i) => Math.max(...table.map((row) => (row[i] ?? '').length)));
|
|
793
|
+
for (const row of table) console.log(row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(' ').trimEnd());
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
if (sub === undefined || sub === 'status') {
|
|
797
|
+
const t = await telemetry();
|
|
798
|
+
console.log(describeTelemetry(t));
|
|
799
|
+
if (t.notice) await telemetry({ notified: true });
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
if (sub === 'on' || sub === 'off') {
|
|
803
|
+
const t = await telemetry({ enabled: sub === 'on' });
|
|
804
|
+
console.log(sub === 'on' ? describeTelemetry(t) : 'Usage counts are off. This inbox stops counting and deleted the days not yet sent.');
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
if (sub === 'show') { console.log(JSON.stringify((await telemetry()).next, null, 2)); break; }
|
|
808
|
+
if (sub === 'reset-id') { console.log(`New install id: ${(await telemetry({ resetId: true })).installId}`); break; }
|
|
809
|
+
fail('Usage: your-attention telemetry status|on|off|show|reset-id, or telemetry report for the operator.');
|
|
810
|
+
}
|
|
811
|
+
default:
|
|
812
|
+
console.log(HELP);
|
|
813
|
+
if (command && command !== 'help' && command !== '--help') process.exit(1);
|
|
814
|
+
}
|