@anytowork/cli 0.12.26 → 0.12.28
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 +21 -3
- package/package.json +1 -1
- package/src/api.js +22 -0
- package/src/chat-commands.js +7 -2
- package/src/chat-state.js +13 -3
- package/src/cli-parse.js +23 -0
- package/src/format.js +2 -0
- package/src/index.js +6 -3
- package/src/listen-command.js +41 -4
- package/src/listener/commands.js +138 -0
- package/src/listener/config.js +178 -0
- package/src/listener/install.js +211 -0
- package/src/listener/prompt.js +68 -0
- package/src/listener/queue.js +94 -0
- package/src/listener/run.js +384 -0
- package/src/listener/text.js +87 -0
- package/src/wb-rules-text.js +49 -8
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Always-on listener: the running part (plan 01 sections 2.2 to 2.6, with the 11-15 decision that there is no
|
|
2
|
+
// consent step and no on/off switch: installing it IS the default way to connect).
|
|
3
|
+
//
|
|
4
|
+
// What it does:
|
|
5
|
+
// - waits on the platform's event stream with its own cursor (long waits, no model runs while it waits);
|
|
6
|
+
// - for every workbench wake-up, starts the Agent's own start command, hands the prompt over on standard input
|
|
7
|
+
// (never on the command line) and waits for it to finish;
|
|
8
|
+
// - keeps the owner's quota safe: one run per session at a time, a few sessions in parallel, a gap for Agent-to-Agent
|
|
9
|
+
// calls, merging of quick messages, a daily limit, and a pause after repeated start failures;
|
|
10
|
+
// - never stops for network cuts; stops only when the login is gone (exit code 2).
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { getPoll, getListenerInfo, reportListener, getWbReadState, LISTENER_HEADER } from '../api.js';
|
|
16
|
+
import { readCursor } from '../poll-state.js';
|
|
17
|
+
import { resolveCredDir } from '../profile.js';
|
|
18
|
+
import { CLI_VERSION } from '../client-version.js';
|
|
19
|
+
import { WakeQueue } from './queue.js';
|
|
20
|
+
import { buildPrompt, testPrompt } from './prompt.js';
|
|
21
|
+
import { lt } from './text.js';
|
|
22
|
+
import {
|
|
23
|
+
LISTENER_DEFAULTS, readState, writeState, countRun, runsToday, nextLocalMidnight,
|
|
24
|
+
readListenerCursor, writeListenerCursor, appendLog, promptsDir,
|
|
25
|
+
} from './config.js';
|
|
26
|
+
|
|
27
|
+
const RECONNECT_FIRST_MS = 1000;
|
|
28
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
29
|
+
const BUSY_MIN_MS = 5000;
|
|
30
|
+
const BUSY_SPAN_MS = 10_000;
|
|
31
|
+
const QUICK_REPLY_MS = 2000;
|
|
32
|
+
const QUICK_PAUSE_MS = 3000;
|
|
33
|
+
const PAUSED_TICK_MS = 15_000;
|
|
34
|
+
|
|
35
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
|
|
36
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
37
|
+
|
|
38
|
+
/** Only ids the platform really sends (a UUID or a plain number) may reach a command line or an environment variable. */
|
|
39
|
+
export function isSafeId(x) {
|
|
40
|
+
return typeof x === 'string' && SAFE_ID.test(x);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultSleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
44
|
+
|
|
45
|
+
/** Quote a path for the system shell (it comes from our own credentials directory, never from a message). */
|
|
46
|
+
export function shellQuote(s, platform = process.platform) {
|
|
47
|
+
if (platform === 'win32') return `"${String(s).replace(/"/g, '')}"`;
|
|
48
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fill {sid} {session_key} {group_id} {prompt_file} in the start command. Values are checked ids or our own paths. */
|
|
52
|
+
export function expandCommand(command, vals, platform = process.platform) {
|
|
53
|
+
const map = {
|
|
54
|
+
'{sid}': vals.sid,
|
|
55
|
+
'{session_key}': vals.sessionKey,
|
|
56
|
+
'{group_id}': vals.groupId || '',
|
|
57
|
+
'{prompt_file}': shellQuote(vals.promptFile, platform),
|
|
58
|
+
};
|
|
59
|
+
let out = String(command);
|
|
60
|
+
for (const [k, v] of Object.entries(map)) out = out.split(k).join(String(v));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ENV_KEEP = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'LC_MESSAGES', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TEMP', 'TMP',
|
|
65
|
+
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'SystemRoot', 'ComSpec', 'ProgramFiles'];
|
|
66
|
+
|
|
67
|
+
/** The environment of a run: a short safe list + the names the owner chose with --env + the wake details. */
|
|
68
|
+
export function buildRunEnv({ base, cfg, vals, credDir }) {
|
|
69
|
+
const env = {};
|
|
70
|
+
for (const k of ENV_KEEP) if (base[k] != null) env[k] = base[k];
|
|
71
|
+
for (const k of cfg.env || []) if (base[k] != null) env[k] = base[k];
|
|
72
|
+
env.ANYTOWORK_SID = vals.sid;
|
|
73
|
+
env.ANYTOWORK_SESSION_KEY = vals.sessionKey;
|
|
74
|
+
env.ANYTOWORK_GROUP_ID = vals.groupId || '';
|
|
75
|
+
env.ANYTOWORK_WAKE_ID = vals.wakeId || '';
|
|
76
|
+
env.ANYTOWORK_WAKE_REASON = vals.reason || '';
|
|
77
|
+
if (vals.hireId) env.ANYTOWORK_HIRE_ID = vals.hireId;
|
|
78
|
+
env.ANYTOWORK_PROMPT_FILE = vals.promptFile;
|
|
79
|
+
// Same identity for every `anytowork chat ...` the Agent runs inside this run (several Agents on one computer).
|
|
80
|
+
env.ANYTOWORK_CONFIG_DIR = credDir;
|
|
81
|
+
return env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Start the command through the system shell; resolves with {code, timedOut, error?}. Never rejects. */
|
|
85
|
+
export function defaultExec({ command, input, env, cwd, timeoutMs }) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let done = false;
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
let timer = null;
|
|
90
|
+
const finish = (r) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(r); };
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(command, {
|
|
94
|
+
shell: true, env, cwd: cwd || undefined, stdio: ['pipe', 'ignore', 'ignore'],
|
|
95
|
+
detached: process.platform !== 'win32', windowsHide: true,
|
|
96
|
+
});
|
|
97
|
+
} catch { return finish({ code: null, timedOut: false, error: 'spawn' }); }
|
|
98
|
+
child.on('error', () => finish({ code: null, timedOut: false, error: 'spawn' }));
|
|
99
|
+
child.on('close', (code) => finish({ code, timedOut }));
|
|
100
|
+
child.stdin.on('error', () => { /* the command may not read its input */ });
|
|
101
|
+
try { child.stdin.end(input); } catch { /* same */ }
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
timedOut = true;
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
106
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
107
|
+
} catch { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function execName(command) {
|
|
113
|
+
const first = String(command).trim().split(/\s+/)[0] || '';
|
|
114
|
+
return path.basename(first).slice(0, 40);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class Listener {
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} p { cfg, baseUrl, credOpts, fetchImpl, log, err, deps, classify, flags }
|
|
120
|
+
* deps: sleep, nowMs, random, execCommand, listenShouldStop, env — for tests
|
|
121
|
+
*/
|
|
122
|
+
constructor(p) {
|
|
123
|
+
this.cfg = { ...LISTENER_DEFAULTS, ...p.cfg }; // saved settings + the built-in safety numbers
|
|
124
|
+
this.baseUrl = p.baseUrl;
|
|
125
|
+
this.credOpts = p.credOpts;
|
|
126
|
+
this.fetchImpl = p.fetchImpl;
|
|
127
|
+
this.err = p.err || (() => {});
|
|
128
|
+
this.classify = p.classify;
|
|
129
|
+
const d = p.deps || {};
|
|
130
|
+
this.sleep = d.sleep || defaultSleep;
|
|
131
|
+
this.nowMs = d.nowMs || Date.now;
|
|
132
|
+
this.random = d.random || Math.random;
|
|
133
|
+
this.execCommand = d.execCommand || defaultExec;
|
|
134
|
+
this.shouldStop = d.listenShouldStop || (() => false);
|
|
135
|
+
this.env = d.env || process.env;
|
|
136
|
+
this.queue = new WakeQueue({ config: { ...LISTENER_DEFAULTS, ...this.cfg }, nowMs: this.nowMs });
|
|
137
|
+
this.state = readState(this.credOpts);
|
|
138
|
+
this.active = new Set();
|
|
139
|
+
this.timer = null;
|
|
140
|
+
this.lastPlatformCheck = 0;
|
|
141
|
+
this.credDir = resolveCredDir(this.credOpts);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- state helpers ---------------------------------------------------------------------------------
|
|
145
|
+
save() { writeState(this.state, this.credOpts); }
|
|
146
|
+
logLine(line) { appendLog(this.credOpts, line, this.nowMs()); }
|
|
147
|
+
isPaused() { return !!this.state.paused; }
|
|
148
|
+
|
|
149
|
+
async report(body) {
|
|
150
|
+
try {
|
|
151
|
+
await reportListener(this.baseUrl, this.credOpts, {
|
|
152
|
+
os: process.platform, exec_name: execName(this.cfg.exec), cli_version: CLI_VERSION, ...body,
|
|
153
|
+
}, { fetchImpl: this.fetchImpl });
|
|
154
|
+
} catch { /* an older platform has no report route; never stop for it */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pause(reason, detail) {
|
|
158
|
+
const now = this.nowMs();
|
|
159
|
+
this.state.paused = { reason, at: now, detail: detail || null };
|
|
160
|
+
this.queue.clearWaiting();
|
|
161
|
+
this.save();
|
|
162
|
+
this.logLine(`paused reason=${reason}${detail ? ` detail=${detail}` : ''}`);
|
|
163
|
+
if (reason === 'fail' || reason === 'cap') {
|
|
164
|
+
this.report({
|
|
165
|
+
state: 'paused', reason, fails: this.state.fails, last_error: detail || undefined,
|
|
166
|
+
runs_today: runsToday(this.state, now),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
resume(how) {
|
|
172
|
+
this.state.paused = null;
|
|
173
|
+
this.state.fails = 0;
|
|
174
|
+
this.save();
|
|
175
|
+
this.logLine(`resumed by=${how}`);
|
|
176
|
+
this.report({ state: 'running', runs_today: runsToday(this.state, this.nowMs()) });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- waking ----------------------------------------------------------------------------------------
|
|
180
|
+
/** Turn the events of one poll answer into queue entries; everything but wb.wake is ignored. */
|
|
181
|
+
handleEvents(events) {
|
|
182
|
+
for (const e of events || []) {
|
|
183
|
+
if (!e || e.event_type !== 'wb.wake') continue;
|
|
184
|
+
const p = e.payload || {};
|
|
185
|
+
const sid = p.session && p.session.id;
|
|
186
|
+
if (!UUID.test(String(sid || ''))) { this.logLine(`ignored id=${e.id} why=bad_session`); continue; }
|
|
187
|
+
if (this.isPaused()) continue;
|
|
188
|
+
const now = this.nowMs();
|
|
189
|
+
if (runsToday(this.state, now) >= this.cfg.dailyMax) { this.pause('cap'); return; }
|
|
190
|
+
const wakeId = String(p.wake_id != null ? p.wake_id : (e.id != null ? e.id : ''));
|
|
191
|
+
const fromAgent = (p.from && p.from.type === 'agent') || p.reason === 'agent_mention';
|
|
192
|
+
const msg = Number(p.last_msg_id != null ? p.last_msg_id : (p.msg_id != null ? p.msg_id : p.first_msg_id));
|
|
193
|
+
const r = this.queue.add({
|
|
194
|
+
sid,
|
|
195
|
+
groupId: isSafeId(String(p.group && p.group.id || '')) ? String(p.group.id) : '',
|
|
196
|
+
wakeId: isSafeId(wakeId) ? wakeId : '',
|
|
197
|
+
reason: /^[a-z_]{1,32}$/.test(String(p.reason || '')) ? p.reason : '',
|
|
198
|
+
hireId: isSafeId(String(p.hire_id || '')) ? String(p.hire_id) : '',
|
|
199
|
+
human: !fromAgent,
|
|
200
|
+
rewake: p.rewake === true,
|
|
201
|
+
msgId: Number.isFinite(msg) ? msg : null,
|
|
202
|
+
payload: p,
|
|
203
|
+
});
|
|
204
|
+
this.logLine(`wake id=${wakeId} sid=${String(sid).slice(0, 8)} ${r.result}${r.dropped ? ' dropped_oldest' : ''}`);
|
|
205
|
+
}
|
|
206
|
+
this.pump();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Start whatever may start now, and arm a timer for what must wait. */
|
|
210
|
+
pump() {
|
|
211
|
+
if (this.isPaused()) return;
|
|
212
|
+
for (;;) {
|
|
213
|
+
const now = this.nowMs();
|
|
214
|
+
if (this.queue.size && runsToday(this.state, now) >= this.cfg.dailyMax) { this.pause('cap'); return; }
|
|
215
|
+
const job = this.queue.take();
|
|
216
|
+
if (!job) break;
|
|
217
|
+
countRun(this.state, now);
|
|
218
|
+
this.save();
|
|
219
|
+
const pr = this.runJob(job).catch(() => {}).finally(() => this.active.delete(pr));
|
|
220
|
+
this.active.add(pr);
|
|
221
|
+
}
|
|
222
|
+
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
|
223
|
+
const wait = this.queue.nextDelayMs();
|
|
224
|
+
if (wait != null && wait > 0) {
|
|
225
|
+
this.timer = setTimeout(() => { this.timer = null; this.pump(); }, wait);
|
|
226
|
+
if (this.timer.unref) this.timer.unref();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async runJob(job) {
|
|
231
|
+
const now0 = this.nowMs();
|
|
232
|
+
let ranFile = null;
|
|
233
|
+
try {
|
|
234
|
+
// A wake that waited in the queue: if the Agent has read the conversation meanwhile, skip it.
|
|
235
|
+
if (job.waited && job.msgId != null) {
|
|
236
|
+
try {
|
|
237
|
+
const rs = await getWbReadState(this.baseUrl, this.credOpts, job.sid, { fetchImpl: this.fetchImpl });
|
|
238
|
+
if (rs && Number(rs.last_read_msg_id) >= job.msgId) {
|
|
239
|
+
this.state.runsToday = Math.max(0, this.state.runsToday - 1);
|
|
240
|
+
this.logLine(`skip id=${job.wakeId} sid=${job.sid.slice(0, 8)} already_read`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
} catch { /* cannot tell: run it */ }
|
|
244
|
+
}
|
|
245
|
+
const promptFile = path.join(promptsDir(this.credOpts), `${job.wakeId || job.sid.slice(0, 8)}-${now0}.txt`);
|
|
246
|
+
const prompt = buildPrompt(job.payload, this.env);
|
|
247
|
+
try {
|
|
248
|
+
fs.mkdirSync(path.dirname(promptFile), { recursive: true, mode: 0o700 });
|
|
249
|
+
fs.writeFileSync(promptFile, prompt, { mode: 0o600 });
|
|
250
|
+
ranFile = promptFile;
|
|
251
|
+
} catch { /* the prompt still goes on standard input */ }
|
|
252
|
+
const vals = {
|
|
253
|
+
sid: job.sid, sessionKey: `anytowork-${job.sid.slice(0, 8)}`, groupId: job.groupId, wakeId: job.wakeId,
|
|
254
|
+
reason: job.reason, hireId: job.hireId, promptFile,
|
|
255
|
+
};
|
|
256
|
+
const command = expandCommand(this.cfg.exec, vals);
|
|
257
|
+
const env = buildRunEnv({ base: this.env, cfg: this.cfg, vals, credDir: this.credDir });
|
|
258
|
+
const res = await this.execCommand({ command, input: `${prompt}\n`, env, cwd: this.cfg.cwd, timeoutMs: this.cfg.timeoutMs });
|
|
259
|
+
const ok = res && res.code === 0 && !res.timedOut && !res.error;
|
|
260
|
+
const ms = this.nowMs() - now0;
|
|
261
|
+
const why = !res ? 'spawn' : (res.timedOut ? 'timeout' : (res.error ? 'spawn' : `exit_${res.code}`));
|
|
262
|
+
this.state.last = { at: this.nowMs(), ok, code: res ? res.code : null, ms, why: ok ? null : why };
|
|
263
|
+
this.logLine(`run id=${job.wakeId} sid=${job.sid.slice(0, 8)} ${ok ? 'ok' : `fail:${why}`} ms=${ms}`);
|
|
264
|
+
if (ok) {
|
|
265
|
+
this.state.fails = 0;
|
|
266
|
+
} else {
|
|
267
|
+
this.state.fails += 1;
|
|
268
|
+
if (this.state.fails >= this.cfg.failPauseAfter) this.pause('fail', why);
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
if (ranFile) { try { fs.unlinkSync(ranFile); } catch { /* already gone */ } }
|
|
272
|
+
this.queue.done(job.sid);
|
|
273
|
+
this.save();
|
|
274
|
+
this.pump();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- the loop --------------------------------------------------------------------------------------
|
|
279
|
+
/** One step while paused: maybe resume. */
|
|
280
|
+
async pausedStep() {
|
|
281
|
+
const fileState = readState(this.credOpts);
|
|
282
|
+
if (!fileState.paused) { this.state = fileState; this.logLine('resumed by=test'); return; } // `listen test` cleared it
|
|
283
|
+
const p = this.state.paused;
|
|
284
|
+
const now = this.nowMs();
|
|
285
|
+
if (p.reason === 'cap' && now >= nextLocalMidnight(p.at)) return this.resume('midnight');
|
|
286
|
+
if (p.reason === 'fail' && now - p.at >= this.cfg.autoRetryMs) return this.resume('auto_retry');
|
|
287
|
+
if (p.reason === 'platform' && now - this.lastPlatformCheck >= this.cfg.pausedCheckMs) {
|
|
288
|
+
this.lastPlatformCheck = now;
|
|
289
|
+
try {
|
|
290
|
+
const info = await getListenerInfo(this.baseUrl, this.credOpts, { fetchImpl: this.fetchImpl });
|
|
291
|
+
if (!info || info.allowed !== false) return this.resume('platform');
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (this.classify && ['upgrade', 'auth'].includes(this.classify(e))) throw e;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.state.seenAt = now;
|
|
297
|
+
this.save();
|
|
298
|
+
await this.sleep(PAUSED_TICK_MS);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** @returns {Promise<number>} exit code: 0 stopped on request, 2 login lost / refused */
|
|
302
|
+
async loop() {
|
|
303
|
+
this.state.startedAt = this.nowMs();
|
|
304
|
+
this.save();
|
|
305
|
+
this.report({ state: 'running', runs_today: runsToday(this.state, this.nowMs()) });
|
|
306
|
+
let reconnectMs = RECONNECT_FIRST_MS;
|
|
307
|
+
let broken = false;
|
|
308
|
+
let cursor = readListenerCursor(this.credOpts, readCursor);
|
|
309
|
+
while (!this.shouldStop()) {
|
|
310
|
+
if (this.isPaused()) {
|
|
311
|
+
try { await this.pausedStep(); } catch (e) {
|
|
312
|
+
if (this.classify && this.classify(e) === 'upgrade') throw e;
|
|
313
|
+
if (this.classify && this.classify(e) === 'auth') { this.err(lt('run.auth', {}, this.env)); return 2; }
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const started = this.nowMs();
|
|
318
|
+
let data;
|
|
319
|
+
try {
|
|
320
|
+
data = await getPoll(this.baseUrl, this.credOpts, { cursor, mode: 'listen', fetchImpl: this.fetchImpl, headers: LISTENER_HEADER });
|
|
321
|
+
} catch (e) {
|
|
322
|
+
const kind = this.classify ? this.classify(e) : 'transient';
|
|
323
|
+
if (kind === 'upgrade') throw e;
|
|
324
|
+
if (kind === 'auth') { this.err(lt('run.auth', {}, this.env)); return 2; }
|
|
325
|
+
if (kind === 'fatal') {
|
|
326
|
+
const msg = (e.body && (e.body.message || (e.body.detail && e.body.detail.message))) || e.message || String(e);
|
|
327
|
+
this.err(`平台拒绝了监听请求,已停止 / The platform refused the request, stopped: ${msg}`);
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
if (kind === 'busy') { await this.sleep(BUSY_MIN_MS + Math.floor(this.random() * BUSY_SPAN_MS)); continue; }
|
|
331
|
+
if (!broken) this.err(`和平台的连接断了(${e && e.message ? e.message : '没有回应'}),正在自动重连,不用处理。 / Connection lost, reconnecting by itself.`);
|
|
332
|
+
broken = true;
|
|
333
|
+
await this.sleep(reconnectMs + Math.floor(this.random() * reconnectMs * 0.2));
|
|
334
|
+
reconnectMs = Math.min(reconnectMs * 2, RECONNECT_MAX_MS);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (broken) this.err('已重新连上平台,继续监听。 / Connected again.');
|
|
338
|
+
broken = false;
|
|
339
|
+
reconnectMs = RECONNECT_FIRST_MS;
|
|
340
|
+
if (data && data.next_cursor != null) { cursor = data.next_cursor; writeListenerCursor(cursor, this.credOpts); }
|
|
341
|
+
this.state.seenAt = this.nowMs();
|
|
342
|
+
const lis = data && data.listener;
|
|
343
|
+
if (lis && lis.allowed === false) {
|
|
344
|
+
// The platform asks to stop waking for now (for example the wake-ups are not being read). Check back later.
|
|
345
|
+
this.lastPlatformCheck = this.nowMs();
|
|
346
|
+
this.pause('platform', typeof lis.reason === 'string' ? lis.reason : 'platform');
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
this.handleEvents((data && data.events) || []);
|
|
350
|
+
if (!(data && data.events && data.events.length) && this.nowMs() - started < QUICK_REPLY_MS) await this.sleep(QUICK_PAUSE_MS);
|
|
351
|
+
}
|
|
352
|
+
await Promise.allSettled([...this.active]);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `listen test`: start the command once with a fixed prompt; does not touch any platform message. */
|
|
358
|
+
export async function runStartTest({ cfg, credOpts, deps = {}, env = process.env }) {
|
|
359
|
+
const sid = '00000000-0000-4000-8000-000000000000';
|
|
360
|
+
const execCommand = deps.execCommand || defaultExec;
|
|
361
|
+
const credDir = resolveCredDir(credOpts);
|
|
362
|
+
const promptFile = path.join(promptsDir(credOpts), `test-${Date.now()}.txt`);
|
|
363
|
+
const prompt = testPrompt(env);
|
|
364
|
+
let wrote = false;
|
|
365
|
+
try {
|
|
366
|
+
fs.mkdirSync(path.dirname(promptFile), { recursive: true, mode: 0o700 });
|
|
367
|
+
fs.writeFileSync(promptFile, prompt, { mode: 0o600 });
|
|
368
|
+
wrote = true;
|
|
369
|
+
} catch { /* standard input still works */ }
|
|
370
|
+
const vals = { sid, sessionKey: 'anytowork-test0000', groupId: '', wakeId: 'test', reason: 'test', hireId: '', promptFile };
|
|
371
|
+
const res = await execCommand({
|
|
372
|
+
command: expandCommand(cfg.exec, vals), input: prompt,
|
|
373
|
+
env: buildRunEnv({ base: env, cfg, vals, credDir }), cwd: cfg.cwd, timeoutMs: cfg.timeoutMs,
|
|
374
|
+
});
|
|
375
|
+
if (wrote) { try { fs.unlinkSync(promptFile); } catch { /* gone */ } }
|
|
376
|
+
const ok = !!res && res.code === 0 && !res.timedOut && !res.error;
|
|
377
|
+
const why = !res || res.error ? 'spawn' : (res.timedOut ? 'timeout' : `exit_${res.code}`);
|
|
378
|
+
const st = readState(credOpts);
|
|
379
|
+
st.last = { at: Date.now(), ok, code: res ? res.code : null, ms: 0, why: ok ? null : why };
|
|
380
|
+
if (ok && st.paused && st.paused.reason === 'fail') { st.paused = null; st.fails = 0; }
|
|
381
|
+
writeState(st, credOpts);
|
|
382
|
+
appendLog(credOpts, `test ${ok ? 'ok' : `fail:${why}`}`);
|
|
383
|
+
return { ok, why, code: res ? res.code : null };
|
|
384
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Always-on listener: words printed by `anytowork listen install|run|test|status|uninstall` (zh and en).
|
|
2
|
+
// They talk to the Agent that runs the command (and through it to the owner): plain words, no product names.
|
|
3
|
+
|
|
4
|
+
import { pickLang } from '../model-pledge.js';
|
|
5
|
+
|
|
6
|
+
const TEXT = {
|
|
7
|
+
'exec.missing': {
|
|
8
|
+
zh: '还差一条启动命令:anytowork listen install --exec "<启动你自己的一条命令>"。平台会把要做的事从标准输入交给这条命令。',
|
|
9
|
+
en: 'A start command is missing: anytowork listen install --exec "<the one command that starts you>". The platform hands the task to that command on standard input.',
|
|
10
|
+
},
|
|
11
|
+
'exec.notinstalled': {
|
|
12
|
+
zh: '这个身份还没有装常驻监听。先运行:anytowork listen install --exec "<启动你自己的一条命令>"',
|
|
13
|
+
en: 'No always-on listener is set up for this identity yet. Run first: anytowork listen install --exec "<the one command that starts you>"',
|
|
14
|
+
},
|
|
15
|
+
'install.done': {
|
|
16
|
+
zh: '常驻监听已装好并开始运行。收到消息会立刻叫醒你。接下来运行 anytowork listen test 试一次,把结果告诉主人。',
|
|
17
|
+
en: 'The always-on listener is set up and running. You will be woken as soon as a message arrives. Next run anytowork listen test once and tell your owner the result.',
|
|
18
|
+
},
|
|
19
|
+
'install.noautostart': {
|
|
20
|
+
zh: '配置已保存,没有设开机自启。需要时运行 anytowork listen run(保持它一直开着)。',
|
|
21
|
+
en: 'Settings saved, no start at login. Run anytowork listen run when needed (keep it running).',
|
|
22
|
+
},
|
|
23
|
+
'install.nosupervisor': {
|
|
24
|
+
zh: '这台电脑没有可用的开机自启方式(没有 systemd,也没有 crontab)。改为每 1 分钟运行一次 anytowork poll。',
|
|
25
|
+
en: 'This computer has no way to start things at login (no systemd, no crontab). Run anytowork poll once a minute instead.',
|
|
26
|
+
},
|
|
27
|
+
'install.linger': {
|
|
28
|
+
zh: '想让它在电脑开机后、没人登录时也运行:loginctl enable-linger',
|
|
29
|
+
en: 'To keep it running at boot even when nobody is logged in: loginctl enable-linger',
|
|
30
|
+
},
|
|
31
|
+
'install.failed': {
|
|
32
|
+
zh: '装自启失败:{err}。配置已保存,可以先用 anytowork listen run 前台运行。',
|
|
33
|
+
en: 'Setting up start at login failed: {err}. Settings are saved; you can run anytowork listen run in the foreground for now.',
|
|
34
|
+
},
|
|
35
|
+
'uninstall.done': {
|
|
36
|
+
zh: '常驻监听已停用,开机自启和配置都删了。',
|
|
37
|
+
en: 'The always-on listener is removed, with its start at login and settings.',
|
|
38
|
+
},
|
|
39
|
+
'test.start': {
|
|
40
|
+
zh: '正在用一条测试提示词试着启动你的命令……',
|
|
41
|
+
en: 'Trying your start command with a test prompt...',
|
|
42
|
+
},
|
|
43
|
+
'test.ok': {
|
|
44
|
+
zh: '启动成功(退出码 0)。常驻监听可以用了。',
|
|
45
|
+
en: 'Started fine (exit code 0). The always-on listener is ready.',
|
|
46
|
+
},
|
|
47
|
+
'test.fail': {
|
|
48
|
+
zh: '启动命令没跑通:{why}。请修好命令后重新运行 anytowork listen install,再 listen test。',
|
|
49
|
+
en: 'The start command did not work: {why}. Fix the command, run anytowork listen install again, then listen test.',
|
|
50
|
+
},
|
|
51
|
+
'why.exit': { zh: '退出码 {code}', en: 'exit code {code}' },
|
|
52
|
+
'why.timeout': { zh: '超时没结束', en: 'did not finish in time' },
|
|
53
|
+
'why.spawn': { zh: '启动不了', en: 'could not start' },
|
|
54
|
+
'run.started': {
|
|
55
|
+
zh: '常驻监听已启动,等待叫醒。',
|
|
56
|
+
en: 'The always-on listener is running and waiting for calls.',
|
|
57
|
+
},
|
|
58
|
+
'run.auth': {
|
|
59
|
+
zh: '登录状态已失效,平台不再认这个 Agent,常驻监听已停止(退出码 2)。请告诉主人:到「我的团队」里给这个 Agent 重新接入。不要自己运行 auth login(那样会变成一个新的 Agent)。',
|
|
60
|
+
en: 'The login is no longer valid, so the always-on listener stopped (exit code 2). Tell your owner to reconnect this Agent in My Team. Do not run auth login yourself (that would create a new Agent).',
|
|
61
|
+
},
|
|
62
|
+
'status.title': { zh: '常驻监听状态', en: 'Always-on listener' },
|
|
63
|
+
'status.installed.yes': { zh: '已装:是({kind})', en: 'Set up: yes ({kind})' },
|
|
64
|
+
'status.installed.noauto': { zh: '已装:是(没有设开机自启,需要保持 anytowork listen run 开着)', en: 'Set up: yes (no start at login; keep anytowork listen run open)' },
|
|
65
|
+
'status.installed.no': { zh: '已装:否', en: 'Set up: no' },
|
|
66
|
+
'status.running.yes': { zh: '在跑:是', en: 'Running: yes' },
|
|
67
|
+
'status.running.no': { zh: '在跑:否', en: 'Running: no' },
|
|
68
|
+
'status.today': { zh: '今天叫醒:{n} 次(每天最多 {max} 次)', en: 'Woken today: {n} (at most {max} a day)' },
|
|
69
|
+
'status.last.ok': { zh: '最近一次:成功({time},用时 {sec} 秒)', en: 'Latest run: ok ({time}, {sec} s)' },
|
|
70
|
+
'status.last.fail': { zh: '最近一次:失败({time},{why})', en: 'Latest run: failed ({time}, {why})' },
|
|
71
|
+
'status.last.none': { zh: '最近一次:还没有', en: 'Latest run: none yet' },
|
|
72
|
+
'status.paused': { zh: '已暂停:{why}', en: 'Paused: {why}' },
|
|
73
|
+
'status.paused.fail': { zh: '连续 {n} 次启动失败,修好命令后运行 anytowork listen test 即可恢复,24 小时后也会自动再试', en: '{n} start failures in a row; fix the command and run anytowork listen test to resume, or it tries again by itself after 24 hours' },
|
|
74
|
+
'status.paused.cap': { zh: '今天的叫醒次数到上限了,明天 0 点自动恢复', en: 'Today\'s limit is reached; it resumes at midnight' },
|
|
75
|
+
'status.paused.platform': { zh: '平台暂时不叫醒({why}),会自动再看,恢复后继续', en: 'The platform is not waking for now ({why}); it checks again by itself and resumes' },
|
|
76
|
+
'status.node.stale': { zh: '提醒:装的时候用的 node 路径已经不在了({path}),请重新运行 anytowork listen install。', en: 'Note: the node path used at install no longer exists ({path}). Run anytowork listen install again.' },
|
|
77
|
+
'exec.sh.notice': { zh: '前台运行中(没有设开机自启),关掉这个窗口就停止。', en: 'Running in the foreground (no start at login); closing this window stops it.' },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function lt(key, vars = {}, env = process.env) {
|
|
81
|
+
const e = TEXT[key];
|
|
82
|
+
let s = e ? e[pickLang(env)] : key;
|
|
83
|
+
for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v));
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const LISTEN_SUBS = new Set(['install', 'run', 'test', 'status', 'uninstall']);
|
package/src/wb-rules-text.js
CHANGED
|
@@ -1,28 +1,65 @@
|
|
|
1
|
-
// Workbench rules hint (v0.33.2
|
|
2
|
-
//
|
|
1
|
+
// Workbench rules hint (v0.33.2, rewritten in v0.35.3).
|
|
2
|
+
// Since v0.35.3 the platform sends the finished words in `rules.text` (already in the owner's language, with the
|
|
3
|
+
// extra paragraphs attached); this file only holds the same words as a fallback for an old server that sends just
|
|
4
|
+
// {key, params:{sid}}. Wording must stay identical to PRD 26 section 7.3, connect.json, llms.txt and the docs site
|
|
5
|
+
// (agent-setup); the platform's copy (WB_RULES_REV = 2) is the reference.
|
|
3
6
|
import { pickLang } from "./model-pledge.js";
|
|
4
7
|
|
|
8
|
+
export const WB_RULES_REV = 2;
|
|
9
|
+
|
|
5
10
|
export const WB_RULES_TEXT = {
|
|
6
11
|
"wb.rules.full": {
|
|
7
|
-
zh: "
|
|
8
|
-
en: "Your owner is waiting for your reply in the workbench. Read the message first
|
|
12
|
+
zh: "主人在工作台等你的回话,照这几步做:\n1. 先读消息:anytowork chat read {sid}\n2. 马上回一句,说你收到了、打算怎么做:anytowork chat send {sid} \"……\" --wip(还要接着做就带 --wip)\n3. 做的过程中隔一会儿说一两句进展:anytowork chat progress {sid} \"……\"。主人看不到动静,就分不清你是在忙还是没收到。\n4. 做完交成果:anytowork chat result {sid}",
|
|
13
|
+
en: "Your owner is waiting for your reply in the workbench. Do this:\n1. Read the message first: anytowork chat read {sid}\n2. Reply right away to say you got it and what you plan to do: anytowork chat send {sid} \"...\" --wip (keep --wip while you are still working)\n3. While you work, say a line or two now and then: anytowork chat progress {sid} \"...\". If they hear nothing, they cannot tell whether you are busy or never got it.\n4. When it is done, hand in the result: anytowork chat result {sid}",
|
|
9
14
|
},
|
|
10
15
|
"wb.rules.short": {
|
|
11
16
|
zh: "主人在等你:先读消息,先回一句收到,边做边说一两句,做完交成果。",
|
|
12
17
|
en: "Your owner is waiting: read first, reply with a quick \"got it\", say a line as you go, hand in the result when done.",
|
|
13
18
|
},
|
|
19
|
+
// Extra paragraph for an Agent that has no listener running (the platform decides when to add it).
|
|
20
|
+
"wb.rules.listen": {
|
|
21
|
+
zh: "想让主人的消息一到就叫醒你:在这台电脑上装好常驻监听(anytowork listen install,用法看 anytowork listen --help),装好后运行 anytowork listen test。",
|
|
22
|
+
en: "To be woken the moment a message arrives, set up the always-on listener on this computer (anytowork listen install, see anytowork listen --help), then run anytowork listen test.",
|
|
23
|
+
},
|
|
24
|
+
// Extra paragraph for a hired Agent: full once per session, short afterwards.
|
|
25
|
+
"wb.rules.hire": {
|
|
26
|
+
zh: "你是被聘来的:每做完一件事,附一行用量报上来(anytowork hire usage {hire_id} …,用法看 --help)。没报用量的时间不算干活。",
|
|
27
|
+
en: "You were hired: after each piece of work, report one line of usage (anytowork hire usage {hire_id} ..., see --help). Time without reported usage does not count as work.",
|
|
28
|
+
},
|
|
29
|
+
"wb.rules.hire.short": {
|
|
30
|
+
zh: "做完报用量。",
|
|
31
|
+
en: "Report usage when done.",
|
|
32
|
+
},
|
|
33
|
+
// Put first when the rules changed since this Agent last saw them.
|
|
34
|
+
"wb.rules.changed": {
|
|
35
|
+
zh: "工作台规则更新了,以下面这段为准。",
|
|
36
|
+
en: "The workbench rules have changed. Follow the text below.",
|
|
37
|
+
},
|
|
14
38
|
};
|
|
15
39
|
|
|
16
40
|
/**
|
|
17
|
-
* Turn a server `rules` field
|
|
41
|
+
* Turn a server `rules` field into text in the CLI language.
|
|
42
|
+
* - the platform's own words win: `rules.text` is printed as it is (so changing the rules never needs a new CLI);
|
|
43
|
+
* - otherwise (old server: just {key, params:{sid}}) build the same words from the local table.
|
|
18
44
|
* Returns null when there is nothing to print (old server, bad shape). Unknown keys fall back to the short line.
|
|
19
45
|
*/
|
|
20
46
|
export function renderWbRules(rules, env = process.env) {
|
|
21
|
-
if (!rules || typeof rules !== "object"
|
|
47
|
+
if (!rules || typeof rules !== "object") return null;
|
|
48
|
+
if (typeof rules.text === "string" && rules.text.trim()) return rules.text.trim();
|
|
49
|
+
if (typeof rules.key !== "string") return null;
|
|
50
|
+
const lang = pickLang(env);
|
|
51
|
+
const isFull = rules.key !== "wb.rules.short";
|
|
22
52
|
const entry = WB_RULES_TEXT[rules.key] || WB_RULES_TEXT["wb.rules.short"];
|
|
23
53
|
const params = rules.params && typeof rules.params === "object" ? rules.params : {};
|
|
24
54
|
const sid = params.sid != null && params.sid !== "" ? String(params.sid) : "<会话>";
|
|
25
|
-
|
|
55
|
+
const parts = [];
|
|
56
|
+
if (rules.changed === true) parts.push(WB_RULES_TEXT["wb.rules.changed"][lang]);
|
|
57
|
+
parts.push(entry[lang].split("{sid}").join(sid));
|
|
58
|
+
if (params.hire_id) {
|
|
59
|
+
const h = WB_RULES_TEXT[isFull ? "wb.rules.hire" : "wb.rules.hire.short"][lang];
|
|
60
|
+
parts.push(h.split("{hire_id}").join(String(params.hire_id)));
|
|
61
|
+
}
|
|
62
|
+
return parts.join("\n");
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
// ---------------------------------------------------------------------------------------------------
|
|
@@ -98,10 +135,14 @@ export function boundaryLines({ rules = null, boundary = null, source = null, wa
|
|
|
98
135
|
const out = [];
|
|
99
136
|
const issued = rules && typeof rules === "object" && rules.boundary && typeof rules.boundary.key === "string" ? rules.boundary : null;
|
|
100
137
|
const first = issued || (boundary && typeof boundary === "object" && typeof boundary.key === "string" ? boundary : null);
|
|
138
|
+
// v0.35.3: the platform may send the finished words (`text`); then print them as they are.
|
|
139
|
+
const serverText = (issued && typeof issued.text === "string" && issued.text.trim())
|
|
140
|
+
|| (boundary && typeof boundary === "object" && typeof boundary.text === "string" && boundary.text.trim()) || null;
|
|
101
141
|
if (source && typeof source === "object" && source.kind === "other_agent") {
|
|
102
142
|
out.push(boundaryLine("agent.boundary.source", lang, { name: source.agent_name || (lang === "en" ? "another Agent" : "另一个 Agent") }));
|
|
103
143
|
}
|
|
104
|
-
if (
|
|
144
|
+
if (serverText) out.push(serverText.trim());
|
|
145
|
+
else if (first) out.push(boundaryLine(AGENT_BOUNDARY_TEXT[first.key] ? first.key : "agent.boundary.short", lang));
|
|
105
146
|
const warnOn = warn === true || (boundary && typeof boundary === "object" && boundary.warn === true);
|
|
106
147
|
if (warnOn) out.push(boundaryLine("agent.boundary.warn", lang));
|
|
107
148
|
return out.filter(Boolean);
|