@anytowork/cli 0.12.26 → 0.12.27

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.
@@ -0,0 +1,200 @@
1
+ // Always-on listener: start at login on macOS, Linux and Windows (plan 01 section 2.5).
2
+ //
3
+ // The generators (plist / unit / task arguments / cron line) are pure functions so tests can check the files without
4
+ // installing anything. They write the absolute path of node and of this command, plus the PATH of the moment of
5
+ // installing, because a login-time job does not see the owner's shell setup.
6
+ // Install is safe to repeat (it replaces what is there); uninstall removes the same things.
7
+
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execFile } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ export function labelOf(profile) { return `work.anytowork.listen.${profile || 'default'}`; }
15
+ export function unitNameOf(profile) { return `anytowork-listen-${profile || 'default'}.service`; }
16
+ export function taskNameOf(profile) { return `AnytoWork Listen ${profile || 'default'}`; }
17
+ export function cronMarkOf(profile) { return `# anytowork-listen:${profile || 'default'}`; }
18
+
19
+ /** The arguments after the entry script that run the listener. */
20
+ export function runArgs(profile) {
21
+ return ['listen', 'run', ...(profile ? ['--profile', profile] : [])];
22
+ }
23
+
24
+ function xml(s) {
25
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
26
+ }
27
+
28
+ /** macOS LaunchAgent plist text. */
29
+ export function launchdPlist({ label, node, entry, profile, pathEnv, configDir, logFile }) {
30
+ const args = [node, entry, ...runArgs(profile)];
31
+ const env = { PATH: pathEnv || '/usr/local/bin:/usr/bin:/bin', ...(configDir ? { ANYTOWORK_CONFIG_DIR: configDir } : {}) };
32
+ return `<?xml version="1.0" encoding="UTF-8"?>
33
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34
+ <plist version="1.0">
35
+ <dict>
36
+ <key>Label</key><string>${xml(label)}</string>
37
+ <key>ProgramArguments</key>
38
+ <array>
39
+ ${args.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
40
+ </array>
41
+ <key>EnvironmentVariables</key>
42
+ <dict>
43
+ ${Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n')}
44
+ </dict>
45
+ <key>RunAtLoad</key><true/>
46
+ <key>KeepAlive</key>
47
+ <dict><key>SuccessfulExit</key><false/></dict>
48
+ <key>ThrottleInterval</key><integer>30</integer>
49
+ <key>StandardOutPath</key><string>${xml(logFile)}</string>
50
+ <key>StandardErrorPath</key><string>${xml(logFile)}</string>
51
+ </dict>
52
+ </plist>
53
+ `;
54
+ }
55
+
56
+ function sdQuote(s) { return `"${String(s).replace(/(["\\])/g, '\\$1')}"`; }
57
+
58
+ /** Linux systemd --user unit text. */
59
+ export function systemdUnit({ node, entry, profile, pathEnv, configDir }) {
60
+ const exec = [node, entry, ...runArgs(profile)].map(sdQuote).join(' ');
61
+ return `[Unit]
62
+ Description=AnytoWork always-on listener (${profile || 'default'})
63
+ After=network-online.target
64
+
65
+ [Service]
66
+ ExecStart=${exec}
67
+ Environment=${sdQuote(`PATH=${pathEnv || '/usr/local/bin:/usr/bin:/bin'}`)}
68
+ ${configDir ? `Environment=${sdQuote(`ANYTOWORK_CONFIG_DIR=${configDir}`)}\n` : ''}Restart=on-failure
69
+ RestartSec=30
70
+
71
+ [Install]
72
+ WantedBy=default.target
73
+ `;
74
+ }
75
+
76
+ function shSingle(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
77
+
78
+ /** The crontab line used when there is no systemd (container, minimal Linux). */
79
+ export function cronLine({ node, entry, profile, configDir }) {
80
+ const cmd = [shSingle(node), shSingle(entry), ...runArgs(profile).map(shSingle)].join(' ');
81
+ const envPart = configDir ? `ANYTOWORK_CONFIG_DIR=${shSingle(configDir)} ` : '';
82
+ return `@reboot ${envPart}${cmd} >/dev/null 2>&1 ${cronMarkOf(profile)}`;
83
+ }
84
+
85
+ /** Windows: the arguments of `schtasks /Create` (run at the user's logon, no administrator needed). */
86
+ export function schtasksCreateArgs({ node, entry, profile }) {
87
+ const tr = [`"${node}"`, `"${entry}"`, ...runArgs(profile)].join(' ');
88
+ return ['/Create', '/TN', taskNameOf(profile), '/SC', 'ONLOGON', '/TR', tr, '/F'];
89
+ }
90
+
91
+ /** Default way to run a system command; resolves {code, stdout, stderr}, never rejects. */
92
+ export function defaultRunCmd(cmd, args, opts = {}) {
93
+ return new Promise((resolve) => {
94
+ try {
95
+ const child = execFile(cmd, args, { windowsHide: true, timeout: 60_000 }, (error, stdout, stderr) => {
96
+ resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || ''), missing: !!(error && error.code === 'ENOENT') });
97
+ });
98
+ if (opts.input != null) { child.stdin.on('error', () => {}); child.stdin.end(opts.input); }
99
+ } catch { resolve({ code: 1, stdout: '', stderr: 'spawn', missing: true }); }
100
+ });
101
+ }
102
+
103
+ /** Absolute path of the command-line entry script (bin/anytowork.js). */
104
+ export function defaultEntryPath() {
105
+ return fileURLToPath(new URL('../../bin/anytowork.js', import.meta.url));
106
+ }
107
+
108
+ /**
109
+ * Set up start at login.
110
+ * @returns {Promise<{kind: 'launchd'|'systemd'|'cron'|'schtasks'|'none', file?: string, notes: string[]}>}
111
+ * kind 'none' = nothing could be set up (the caller says to use poll every minute instead).
112
+ */
113
+ export async function installAutostart(o) {
114
+ const platform = o.platform || process.platform;
115
+ const runCmd = o.runCmd || defaultRunCmd;
116
+ const home = o.home || os.homedir();
117
+ const node = o.node || process.execPath;
118
+ const entry = o.entry || defaultEntryPath();
119
+ const pathEnv = o.pathEnv != null ? o.pathEnv : process.env.PATH;
120
+ const common = { node, entry, profile: o.profile || null, pathEnv, configDir: o.configDir || null };
121
+ const notes = [];
122
+
123
+ if (platform === 'darwin') {
124
+ const label = labelOf(o.profile);
125
+ const dir = path.join(home, 'Library', 'LaunchAgents');
126
+ const file = path.join(dir, `${label}.plist`);
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ fs.writeFileSync(file, launchdPlist({ ...common, label, logFile: o.logFile }), { mode: 0o644 });
129
+ const uid = String(o.uid != null ? o.uid : (process.getuid ? process.getuid() : 501));
130
+ await runCmd('launchctl', ['bootout', `gui/${uid}/${label}`]); // ignore: it may not be loaded yet
131
+ const r = await runCmd('launchctl', ['bootstrap', `gui/${uid}`, file]);
132
+ if (r.code !== 0) throw new Error(`launchctl: ${(r.stderr || '').trim().slice(0, 200)}`);
133
+ return { kind: 'launchd', file, notes };
134
+ }
135
+
136
+ if (platform === 'win32') {
137
+ const create = await runCmd('schtasks', schtasksCreateArgs(common));
138
+ if (create.code !== 0) throw new Error(`schtasks: ${(create.stderr || create.stdout || '').trim().slice(0, 200)}`);
139
+ await runCmd('schtasks', ['/Run', '/TN', taskNameOf(o.profile)]);
140
+ return { kind: 'schtasks', notes };
141
+ }
142
+
143
+ // Linux and the rest: systemd --user first, then crontab @reboot, otherwise nothing.
144
+ const sd = await runCmd('systemctl', ['--user', '--version']);
145
+ if (sd.code === 0 && !sd.missing) {
146
+ const dir = path.join(home, '.config', 'systemd', 'user');
147
+ const file = path.join(dir, unitNameOf(o.profile));
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(file, systemdUnit(common), { mode: 0o644 });
150
+ await runCmd('systemctl', ['--user', 'daemon-reload']);
151
+ const r = await runCmd('systemctl', ['--user', 'enable', '--now', unitNameOf(o.profile)]);
152
+ if (r.code === 0) { notes.push('linger'); return { kind: 'systemd', file, notes }; }
153
+ // The user manager is not reachable (container, ssh without a session): fall through to cron.
154
+ }
155
+ const ct = await runCmd('crontab', ['-l']);
156
+ if (!ct.missing) {
157
+ const mark = cronMarkOf(o.profile);
158
+ const kept = (ct.code === 0 ? ct.stdout : '').split('\n').filter((l) => l && !l.includes(mark));
159
+ kept.push(cronLine(common));
160
+ const w = await runCmd('crontab', ['-'], { input: `${kept.join('\n')}\n` });
161
+ if (w.code === 0) {
162
+ if (o.startNow) await o.startNow();
163
+ return { kind: 'cron', notes };
164
+ }
165
+ }
166
+ return { kind: 'none', notes };
167
+ }
168
+
169
+ /** Undo installAutostart; unknown or missing pieces are skipped. */
170
+ export async function uninstallAutostart(o) {
171
+ const platform = o.platform || process.platform;
172
+ const runCmd = o.runCmd || defaultRunCmd;
173
+ const home = o.home || os.homedir();
174
+ if (platform === 'darwin') {
175
+ const label = labelOf(o.profile);
176
+ const uid = String(o.uid != null ? o.uid : (process.getuid ? process.getuid() : 501));
177
+ await runCmd('launchctl', ['bootout', `gui/${uid}/${label}`]);
178
+ try { fs.unlinkSync(path.join(home, 'Library', 'LaunchAgents', `${label}.plist`)); } catch { /* none */ }
179
+ return;
180
+ }
181
+ if (platform === 'win32') {
182
+ await runCmd('schtasks', ['/End', '/TN', taskNameOf(o.profile)]);
183
+ await runCmd('schtasks', ['/Delete', '/TN', taskNameOf(o.profile), '/F']);
184
+ return;
185
+ }
186
+ const unit = path.join(home, '.config', 'systemd', 'user', unitNameOf(o.profile));
187
+ if (fs.existsSync(unit)) {
188
+ await runCmd('systemctl', ['--user', 'disable', '--now', unitNameOf(o.profile)]);
189
+ try { fs.unlinkSync(unit); } catch { /* none */ }
190
+ await runCmd('systemctl', ['--user', 'daemon-reload']);
191
+ }
192
+ const ct = await runCmd('crontab', ['-l']);
193
+ if (!ct.missing && ct.code === 0) {
194
+ const mark = cronMarkOf(o.profile);
195
+ if (ct.stdout.includes(mark)) {
196
+ const kept = ct.stdout.split('\n').filter((l) => l && !l.includes(mark));
197
+ await runCmd('crontab', ['-'], { input: kept.length ? `${kept.join('\n')}\n` : '' });
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,68 @@
1
+ // Always-on listener: the words handed to the Agent when it is woken (plan 01 section 2.2).
2
+ //
3
+ // The prompt travels only on standard input and in a temporary file, never on a command line. Everything the
4
+ // platform sent as finished words (`rules.text`, `boundary.text`) is used as it is; the local tables in
5
+ // wb-rules-text.js are only the fallback for an older platform.
6
+
7
+ import { renderWbRules, boundaryLines, identityText } from '../wb-rules-text.js';
8
+ import { pickLang } from '../model-pledge.js';
9
+
10
+ const REWAKE_LINE = {
11
+ zh: '刚才叫过你一次,还没看到你读消息。',
12
+ en: 'You were called a moment ago and have not read the message yet.',
13
+ };
14
+
15
+ function langOf(payload, env) {
16
+ const l = payload && typeof payload.lang === 'string' ? payload.lang.toLowerCase() : '';
17
+ if (l.startsWith('en')) return 'en';
18
+ if (l.startsWith('zh')) return 'zh';
19
+ return pickLang(env);
20
+ }
21
+
22
+ /**
23
+ * @param {object} payload the `wb.wake` payload ({group, session, from, summary, rules, boundary, rewake, ...})
24
+ * @returns {string}
25
+ */
26
+ export function buildPrompt(payload, env = process.env) {
27
+ const p = payload || {};
28
+ const lang = langOf(p, env);
29
+ const envLang = { ...env, ANYTOWORK_LANG: lang };
30
+ const g = p.group || {};
31
+ const s = p.session || {};
32
+ const sid = s.id || '';
33
+ const place = g.kind === 'dm'
34
+ ? (lang === 'en' ? 'private chat' : '私聊')
35
+ : (g.name || (lang === 'en' ? 'group chat' : '群聊'));
36
+ const where = s.title ? `${place} / ${s.title}` : place;
37
+ const from = p.from || {};
38
+ const who = identityText(from, envLang)
39
+ || (from.type === 'agent' ? (from.name || (lang === 'en' ? 'another Agent' : '另一个 Agent'))
40
+ : (from.type === 'member' ? (from.name || (lang === 'en' ? 'a group member' : '群成员'))
41
+ : (lang === 'en' ? 'Your owner' : '主人')));
42
+ const lines = [];
43
+ if (lang === 'en') {
44
+ lines.push(`[AnytoWork] ${who} called you in "${where}": ${p.summary || ''}`);
45
+ } else {
46
+ lines.push(`[AnytoWork] ${who}在「${where}」叫你:${p.summary || ''}`);
47
+ }
48
+ // A message from another owner's Agent carries a fixed source line (empty for anyone else).
49
+ for (const l of boundaryLines({ source: p.source }, envLang)) lines.push(l);
50
+ const rules = renderWbRules(p.rules, envLang);
51
+ if (rules) lines.push(rules);
52
+ for (const l of boundaryLines({ rules: p.rules, boundary: p.boundary }, envLang)) lines.push(l);
53
+ if (p.workspace_path) {
54
+ lines.push(lang === 'en' ? `Files of this group are in the space "${p.workspace_path}".` : `这个群的文件放在空间「${p.workspace_path}」`);
55
+ }
56
+ lines.push(lang === 'en'
57
+ ? `Session id ${sid}. This is an automatic call from the platform.`
58
+ : `会话编号 ${sid}。这是平台自动叫醒。`);
59
+ if (p.rewake === true) lines.push(REWAKE_LINE[lang]);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ /** The fixed prompt used by `listen test`: it asks the Agent to do nothing but answer. */
64
+ export function testPrompt(env = process.env) {
65
+ return pickLang(env) === 'en'
66
+ ? '[AnytoWork] This is a start-up test of the listener. Do nothing in AnytoWork; just reply "ok".'
67
+ : '[AnytoWork] 这是常驻监听的启动测试。不用在 AnytoWork 里做任何事,回一句「好」即可。';
68
+ }
@@ -0,0 +1,94 @@
1
+ // Always-on listener: who runs next (plan 01 section 2.3).
2
+ //
3
+ // Pure bookkeeping, no timers and no processes, so it can be tested with a fake clock.
4
+ // - one session never runs twice at once: a wake that arrives while it runs (or waits) is merged into ONE waiting
5
+ // run that starts after the current one ends;
6
+ // - at most `maxParallel` sessions run at the same time;
7
+ // - another Agent calling the same session is let through at most once per `agentWakeGapMs`
8
+ // (people are never limited; the platform's own second call, `rewake`, is not limited either);
9
+ // - messages from people inside `humanMergeMs` of the last start are merged into one run;
10
+ // - at most `queueMax` sessions wait; beyond that the oldest is dropped (the platform's second call covers it).
11
+
12
+ import { LISTENER_DEFAULTS } from './config.js';
13
+
14
+ export class WakeQueue {
15
+ /**
16
+ * @param {object} opts { config?: partial limits, nowMs?: () => number }
17
+ */
18
+ constructor(opts = {}) {
19
+ this.cfg = { ...LISTENER_DEFAULTS, ...(opts.config || {}) };
20
+ this.nowMs = opts.nowMs || Date.now;
21
+ this.waiting = new Map(); // sid -> job waiting to start
22
+ this.running = new Set(); // sid
23
+ this.lastStart = new Map(); // sid -> ms of the latest start
24
+ }
25
+
26
+ /**
27
+ * @param {object} wake { sid, human, rewake, ... any other fields are kept on the job }
28
+ * @returns {{result: 'queued'|'merged'|'throttled', dropped?: string}}
29
+ */
30
+ add(wake) {
31
+ const now = this.nowMs();
32
+ const sid = wake.sid;
33
+ const last = this.lastStart.get(sid);
34
+ if (!wake.human && !wake.rewake && last != null && now - last < this.cfg.agentWakeGapMs) {
35
+ return { result: 'throttled' };
36
+ }
37
+ const have = this.waiting.get(sid);
38
+ if (have) {
39
+ // Merge: the newest wake's details, the earliest arrival time, "human" if any of them was.
40
+ this.waiting.set(sid, { ...have, ...wake, addedAt: have.addedAt, readyAt: have.readyAt, human: have.human || wake.human });
41
+ return { result: 'merged' };
42
+ }
43
+ let readyAt = now;
44
+ if (wake.human && last != null && now - last < this.cfg.humanMergeMs) readyAt = last + this.cfg.humanMergeMs;
45
+ this.waiting.set(sid, { ...wake, addedAt: now, readyAt });
46
+ let dropped;
47
+ if (this.waiting.size > this.cfg.queueMax) {
48
+ let oldest = null;
49
+ for (const j of this.waiting.values()) if (!oldest || j.addedAt < oldest.addedAt) oldest = j;
50
+ this.waiting.delete(oldest.sid);
51
+ dropped = oldest.sid;
52
+ }
53
+ return { result: 'queued', dropped };
54
+ }
55
+
56
+ /** The next job that may start now (oldest first), or null. Marks it running. */
57
+ take() {
58
+ const now = this.nowMs();
59
+ if (this.running.size >= this.cfg.maxParallel) return null;
60
+ let pick = null;
61
+ for (const j of this.waiting.values()) {
62
+ if (this.running.has(j.sid) || j.readyAt > now) continue;
63
+ if (!pick || j.addedAt < pick.addedAt) pick = j;
64
+ }
65
+ if (!pick) return null;
66
+ this.waiting.delete(pick.sid);
67
+ this.running.add(pick.sid);
68
+ this.lastStart.set(pick.sid, now);
69
+ return { ...pick, waited: now - pick.addedAt > 2000 };
70
+ }
71
+
72
+ /** A run ended. */
73
+ done(sid) {
74
+ this.running.delete(sid);
75
+ }
76
+
77
+ /** Milliseconds until the earliest waiting job could start by time alone, or null when nothing waits. */
78
+ nextDelayMs() {
79
+ const now = this.nowMs();
80
+ let best = null;
81
+ for (const j of this.waiting.values()) {
82
+ const d = Math.max(0, j.readyAt - now);
83
+ if (best == null || d < best) best = d;
84
+ }
85
+ return best;
86
+ }
87
+
88
+ /** Forget everything waiting (the listener paused). */
89
+ clearWaiting() {
90
+ this.waiting.clear();
91
+ }
92
+
93
+ get size() { return this.waiting.size; }
94
+ }