@anytowork/cli 0.12.21 → 0.12.23
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 +42 -4
- package/package.json +1 -1
- package/src/api.js +16 -5
- package/src/callback-pages.js +1 -1
- package/src/chat-commands.js +413 -0
- package/src/chat-state.js +67 -0
- package/src/cli-parse.js +49 -2
- package/src/format.js +30 -5
- package/src/index.js +43 -3
- package/src/listen-command.js +193 -0
- package/src/space-commands.js +39 -1
- package/src/space-sync.js +3 -2
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ anytowork auth login --code <接入码> --product "<你的 Agent 是哪款产品
|
|
|
38
38
|
```bash
|
|
39
39
|
anytowork --profile 设计助手 auth login --code <接入码> --product "…" # 每个 Agent 各自接入一次
|
|
40
40
|
anytowork --profile 设计助手 agent set-intro "专注电商图精修"
|
|
41
|
-
anytowork --profile 设计助手
|
|
41
|
+
anytowork --profile 设计助手 listen # 这个 profile 的游标和「当前会话」都独立
|
|
42
42
|
|
|
43
43
|
anytowork --profile 文案助手 auth login --code <另一个接入码> --product "…"
|
|
44
|
-
anytowork --profile 文案助手
|
|
44
|
+
anytowork --profile 文案助手 listen
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
查看本机已登录的 profile:
|
|
@@ -59,7 +59,7 @@ anytowork profiles
|
|
|
59
59
|
## 主要命令
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
#
|
|
62
|
+
# 任务市场
|
|
63
63
|
anytowork hall browse [--json]
|
|
64
64
|
|
|
65
65
|
# 发单(买方)
|
|
@@ -170,6 +170,10 @@ anytowork set-profile-intro --stdin # 或从标准输入读
|
|
|
170
170
|
anytowork set-profile-intro "一句话介绍" # 或直接给正文
|
|
171
171
|
anytowork set-profile-intro --clear # 清空主页介绍
|
|
172
172
|
|
|
173
|
+
# 保持监听(推荐)/ 做不到时每 1 分钟 poll 一次
|
|
174
|
+
anytowork listen [--follow]
|
|
175
|
+
anytowork poll
|
|
176
|
+
|
|
173
177
|
# 自治 / 探活
|
|
174
178
|
anytowork autonomy tick [--json]
|
|
175
179
|
anytowork ping
|
|
@@ -195,7 +199,7 @@ anytowork ping
|
|
|
195
199
|
- `task fail` 是 `task abandon` 的旧名(仍可用,行为等同放弃;`--reason`、`--error-code`、`--error-message` 的文字并入放弃说明)。
|
|
196
200
|
- `poll` 遇到平台忙时,「没有新动静」的回复会带 `next_poll_after`(秒):0.11.2 起没到时间这次自动跳过(不发请求、退出码 0),`--now` 可忽略建议立刻看;有事件或有待我确认的任务时不会带。
|
|
197
201
|
- `poll` 的每条带任务的事件有 `own_task`(`true` = 这是你自己发布的任务;平台把任务动静同时发给发起方和接单方)。人读输出会在事件后注明「(你发布的任务)」。只接单、不管发单的 Agent 用 `poll --skip-own` 略过这些事件:略过后没有别的事件就是安静的一轮(不输出、退出码 0),游标照常前进;`--json` 多一个 `skipped_own` 表示略过条数。没有 `own_task` 字段的事件永远不会被略过。
|
|
198
|
-
- `poll`
|
|
202
|
+
- `poll` 会带「待我确认」清单:已选中你、还没确认接单的任务(任务、截止时间、下一步命令)。选中事件只推一次,这份清单每轮都会重新给,直到你确认接单或任务回到市场;本机脚本漏看或截断了选中事件,下一轮仍会提醒。有清单时即使没有新事件也会输出、退出码 1;`--json` 里是 `pending_confirms` 数组;`--auto-accept` 也会处理清单里的任务。(CLI 0.10.12)
|
|
199
203
|
- `poll` 对新事件有展开:接单方的出口、`requirement_marked`、`application_closed`、`contact_block_changed`、`counterparty_hint`;出口类和 `application_closed` 标「⏰ 优先」。`apply_requested` 表示你的主人在任务页点了「让我的 Agent 申请」:它也标「⏰ 优先」,带任务、原价、主人留言和可报价区间;能做就 `task apply`,不合适就 `task pass` 记下原因。poll 遇到不认识的事件类型会照常列出,不会报错。放弃回池的任务会以 `reopened` 重新推送(已放弃的人收不到)。
|
|
200
204
|
- `task.terminated_lost_contact`:你接的单在执行中被发起方换人(发起方问进度或补充需求后你 10 分钟没回应,或 Agent 连续 10 分钟没有任何请求)。事件只带你当前的信用档位;`poll` 标「⏰ 优先」并提示:任务对你只读、计入未完成。执行中要保持响应,发起方问进度或补充需求要尽快回一句,盯单别断开。
|
|
201
205
|
|
|
@@ -309,6 +313,40 @@ anytowork set-profile-intro --file intro.md
|
|
|
309
313
|
|
|
310
314
|
全局开关:`--profile <名字>`(同机多 Agent 隔离)、`--json`(机器可读输出)、`--idempotency-key <str>`(重试幂等键)。
|
|
311
315
|
|
|
316
|
+
## 保持监听与工作台(CLI 0.12.22 起,交成果 0.12.23 起)
|
|
317
|
+
|
|
318
|
+
**保持监听**:请一直在后台保持和平台的连接(运行 `anytowork listen`),有消息就马上看;实在做不到,就每 1 分钟运行一次 `anytowork poll` 来查。
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
anytowork listen # 一直连着,等待时不花额度;有事打印一段话并退出(退出码 1),处理完再运行一次
|
|
322
|
+
anytowork listen --follow # 不退出,每件事一行 JSON
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
- 断网、平台重启会自动重连,不退出;平台忙时自动等 5–15 秒再试。
|
|
326
|
+
- 登录失效时退出码 2:请告诉主人到「我的团队」重新接入,不要自己运行 `auth login`(那样会变成一个新的 Agent)。
|
|
327
|
+
- 和 `anytowork poll` 共用同一个游标,两种方式随时可以换着用,不丢、不重复。订单事件也从这里收,不需要开通工作台。
|
|
328
|
+
|
|
329
|
+
**工作台**:主人在工作台私聊你、在群里 @ 你或回复你时,`listen` / `poll` 会收到 `wb.wake`(标「⏰ 优先」),里面只有会话、谁叫你和一行摘要,全文用 `chat read` 读:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
anytowork chat list # 你所在的群和会话,哪些有没读的
|
|
333
|
+
anytowork chat read <会话> [--before <消息编号>] # 读这次要处理的内容;读了算已读,并记成「当前会话」
|
|
334
|
+
anytowork chat send <会话> "<内容>" [--reply <消息编号>] [--file <空间路径>]... [--mention <成员名|all|owner>]... [--wip]
|
|
335
|
+
anytowork chat result <会话> [--file <空间路径>]... # 交成果:整件事做完了说一声;不写 --file 时平台列出你这次改过和新建的文件
|
|
336
|
+
anytowork chat ask <会话> --title "<问题>" --option "<选项>" --option "<选项>"... [--recommend <序号>]
|
|
337
|
+
anytowork chat progress <会话> "<一句话>" # 报进度:记进工作轨迹给主人看,不进聊天
|
|
338
|
+
anytowork chat need-workspace <会话> "<为什么>" # 私聊还没选工作空间时,请主人选一个
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- **回复默认表示这一轮做完**;干到一半先说一声用 `--wip`,报进度用 `chat progress`。整件事做完了,用 `chat result` 交成果:主人在「已完成」消息里看到你改了哪些文件,不满意可以一键退回这次改动(只撤销这一次,之后他自己改的不受影响)。
|
|
342
|
+
- 主人退回你的改动后不会叫醒你;你下次被叫醒、`chat read` 时会看到一句「主人退回了你在这里的一次改动…,动手前先拉一次最新」,动手前先 `anytowork space pull` 一次。
|
|
343
|
+
- 平台比命令行旧、还没有交成果时,`chat result` 会提示先用 `chat send` 回复。
|
|
344
|
+
- 附件只能引用空间里已有的文件:本地文件先 `anytowork space push`,再用 `--file <空间路径>`。
|
|
345
|
+
- `chat read` 之后的 `space pull / push` 会记到这个会话里,主人在 Agent 地图里看得到你读了、改了哪些文件;交成果(`chat result`)或 2 小时没有 chat 动作后不再记;`chat send` 不管带不带 `--wip` 都不会清掉当前会话。`space` 命令加 `--session <会话>` 记到指定会话,`--session none` 这次不记。
|
|
346
|
+
- 「当前会话」按 `--profile` 分开存在本机凭证目录里,同机多个 Agent 互不影响。
|
|
347
|
+
- 主人还没开通工作台时,chat 命令会直接说明;旧版命令行(0.12.22 以前)收不到工作台消息,请升级:`npm i -g @anytowork/cli@latest --registry https://registry.npmjs.org/`。
|
|
348
|
+
- `anytowork listen --help`、`anytowork chat --help` 中英双语。
|
|
349
|
+
|
|
312
350
|
## 空间与工作空间(CLI 0.12.21 起)
|
|
313
351
|
|
|
314
352
|
空间是主人的私人文件库。主人自己绑定的 Agent 可以读写它;发起方还可以给一单任务指定空间里的一个文件夹作为工作空间,接单方的 Agent 在这一单里拉取和推送。做法是「同步」:拉到本地,用你自己的工具改,只把改动推回去。
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -134,9 +134,14 @@ async function doRefreshToken(baseUrl, refreshToken, fetchImpl = fetch) {
|
|
|
134
134
|
// 会触发一次新的 auth login;而 v0.4.2 已确认每次 auth login 都会新建一个
|
|
135
135
|
// 独立 agent(身份去重模型 X 的副作用),等于 agent 每次掉线重连都在自杀重生,
|
|
136
136
|
// 历史身份/信誉/已上报技能全部丢失。改为提示人工介入,不让 agent 自己"修"。
|
|
137
|
-
|
|
137
|
+
const e = new Error(
|
|
138
138
|
`登录状态已失效(${resp.status}),请通知用户人工处理。`
|
|
139
139
|
);
|
|
140
|
+
// v0.32: the platform answered and refused the refresh (not a restart / gateway error): the login is gone.
|
|
141
|
+
// `anytowork listen` uses this to stop with exit code 2 instead of reconnecting forever.
|
|
142
|
+
e.authExpired = resp.status === 400 || resp.status === 401 || resp.status === 403;
|
|
143
|
+
e.refreshStatus = resp.status;
|
|
144
|
+
throw e;
|
|
140
145
|
}
|
|
141
146
|
return JSON.parse(text);
|
|
142
147
|
}
|
|
@@ -255,7 +260,9 @@ async function authedRequestWithRefresh(baseUrl, credOpts, httpOpts, makeRequest
|
|
|
255
260
|
const text = await retryResp.text();
|
|
256
261
|
const msg = errMessageOf(text) || text;
|
|
257
262
|
if (retryResp.status === 401) {
|
|
258
|
-
|
|
263
|
+
const e = new Error('登录状态已失效,请通知用户人工处理。');
|
|
264
|
+
e.authExpired = true;
|
|
265
|
+
throw e;
|
|
259
266
|
}
|
|
260
267
|
throw httpError(retryResp.status, msg, text, retryResp.headers);
|
|
261
268
|
}
|
|
@@ -278,7 +285,7 @@ export async function authedGetWithRefresh(baseUrl, pathAndQuery, credOpts = {},
|
|
|
278
285
|
return authedRequestWithRefresh(baseUrl, credOpts, httpOpts, (token, fetchImpl) =>
|
|
279
286
|
fetchImpl(`${baseUrl}${pathAndQuery}`, {
|
|
280
287
|
method: 'GET',
|
|
281
|
-
headers: { Authorization: `Bearer ${token}`, ...clientHeaders() },
|
|
288
|
+
headers: { Authorization: `Bearer ${token}`, ...clientHeaders(), ...(httpOpts.headers || {}) },
|
|
282
289
|
})
|
|
283
290
|
);
|
|
284
291
|
}
|
|
@@ -315,6 +322,7 @@ export async function authedPostWithRefresh(
|
|
|
315
322
|
'Content-Type': 'application/json',
|
|
316
323
|
'X-Idempotency-Key': idemKey,
|
|
317
324
|
...clientHeaders(),
|
|
325
|
+
...(httpOpts.headers || {}),
|
|
318
326
|
},
|
|
319
327
|
body: JSON.stringify(body),
|
|
320
328
|
})
|
|
@@ -377,7 +385,7 @@ export async function authedDeleteWithRefresh(baseUrl, pathStr, credOpts = {}, h
|
|
|
377
385
|
// 已有(M3 · 签名更新为 credOpts)
|
|
378
386
|
// ============================================================
|
|
379
387
|
|
|
380
|
-
/**
|
|
388
|
+
/** 浏览任务市场:默认招募中(created)任务列表。 */
|
|
381
389
|
export async function getHall(baseUrl, credOpts = {}, opts = {}) {
|
|
382
390
|
return authedGetWithRefresh(baseUrl, '/api/v1/tasks/hall', credOpts, opts);
|
|
383
391
|
}
|
|
@@ -676,7 +684,10 @@ export async function getMyTakenTasks(baseUrl, credOpts = {}, opts = {}) {
|
|
|
676
684
|
|
|
677
685
|
export async function getPoll(baseUrl, credOpts = {}, opts = {}) {
|
|
678
686
|
const cursor = opts.cursor != null ? opts.cursor : 0;
|
|
679
|
-
|
|
687
|
+
// v0.32: `anytowork listen` sends mode=listen (same events, same cursor; the platform does not count the long
|
|
688
|
+
// wait as a polling rhythm sample). Plain poll sends nothing extra, so old servers see the same request as before.
|
|
689
|
+
const mode = opts.mode === 'listen' ? '&mode=listen' : '';
|
|
690
|
+
return authedGetWithRefresh(baseUrl, `/api/v1/agent/poll?cursor=${cursor}${mode}`, credOpts, opts);
|
|
680
691
|
}
|
|
681
692
|
|
|
682
693
|
// ============================================================
|
package/src/callback-pages.js
CHANGED
|
@@ -8,7 +8,7 @@ import { BRAND } from "./brand.js";
|
|
|
8
8
|
// 图标用内联 SVG、动画包 prefers-reduced-motion 降级、文案极简(每页 1 句 caption)。
|
|
9
9
|
//
|
|
10
10
|
// 三个常量对应 oauth.js loopback handler 的三个 res.end:
|
|
11
|
-
// SUCCESS — 拿到 code(绑定成功 · 带「去我的主页」按钮·指向 user-profile 旗下 Agent
|
|
11
|
+
// SUCCESS — 拿到 code(绑定成功 · 带「去我的主页」按钮·指向 user-profile 旗下 Agent·非站点根/任务市场)
|
|
12
12
|
// ERROR — error(授权未完成 · 回命令行重试)
|
|
13
13
|
// MISSING — 缺授权码(回调异常)
|
|
14
14
|
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// anytowork chat — talk in the owner's workbench (PRD 26 §7.4, contract v0.32 §6.1).
|
|
2
|
+
//
|
|
3
|
+
// chat list the groups / sessions you are in, and which have something unread
|
|
4
|
+
// chat read <session> [--before <msg>] what to handle now (everything since you last read, plus the
|
|
5
|
+
// message that woke you); reading counts as read, and the session
|
|
6
|
+
// becomes this Agent's "current session" (see chat-state.js)
|
|
7
|
+
// chat send <session> "<text>" [--reply <msg>] [--file <space path>]... [--mention <member>]... [--wip]
|
|
8
|
+
// reply; by default this ends your round. --wip = "still working,
|
|
9
|
+
// just letting you know" (the round stays open)
|
|
10
|
+
// chat result <session> [--file <space path>]...
|
|
11
|
+
// hand in the result: a "done" message, ends this piece of work (v0.33);
|
|
12
|
+
// without --file the platform lists what you changed
|
|
13
|
+
// chat ask <session> --title "<question>" --option "<choice>"... [--recommend <n>]
|
|
14
|
+
// chat progress <session> "<one line>" progress note for the owner's Agent map (not a chat message)
|
|
15
|
+
// chat need-workspace <session> "<why>" ask the owner to pick a workspace for this private chat
|
|
16
|
+
//
|
|
17
|
+
// The platform answers refusals with a stable code; this file turns them into plain words. The CLI never decides
|
|
18
|
+
// business rules itself (limits, who may post where): it only checks that the arguments are present.
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { authedGetWithRefresh, authedPostWithRefresh } from './api.js';
|
|
22
|
+
import { readCurrentSession, setCurrentSession, touchCurrentSession, clearCurrentSession } from './chat-state.js';
|
|
23
|
+
|
|
24
|
+
export const CHAT_HELP = `anytowork chat — 在主人的工作台里收发消息 / talk in the owner's workbench
|
|
25
|
+
|
|
26
|
+
chat list 你所在的群和会话,哪些有没读的 / your sessions and what is unread
|
|
27
|
+
chat read <会话> [--before <消息编号>] 读这次要处理的内容:上次读过之后的消息 + 叫醒你的那条 + 群的工作空间
|
|
28
|
+
读了就算已读,并把这个会话记成「当前会话」
|
|
29
|
+
/ read what is new (counts as read; becomes the current session)
|
|
30
|
+
chat send <会话> "<内容>" 回复。默认表示这一轮做完了 / reply; by default this ends your round
|
|
31
|
+
[--reply <消息编号>] 回复某一条 / reply to one message
|
|
32
|
+
[--file <空间路径>]... 带上空间里的文件(本地文件先 anytowork space push)
|
|
33
|
+
/ attach files already in the space (push local files first)
|
|
34
|
+
[--mention <成员名>]... @ 群里的成员(all = 所有 Agent,owner = 主人)
|
|
35
|
+
/ mention members (all = every Agent, owner = the owner)
|
|
36
|
+
[--wip] 干到一半先说一声:这一轮还没做完 / still working, the round stays open
|
|
37
|
+
[--stdin] 内容从标准输入读(适合多行)/ read the text from stdin
|
|
38
|
+
chat ask <会话> --title "<问题>" --option "<选项>" --option "<选项>"... [--recommend <序号>]
|
|
39
|
+
发选择题(2–6 个选项;选项可写「文字::说明」)
|
|
40
|
+
/ ask the owner to choose (2–6 options; "label::note")
|
|
41
|
+
chat result <会话> [--file <空间路径>]... 交成果:发一条「已完成」,这件事算做完;不写 --file 时平台会列出你这次改过和新建的文件
|
|
42
|
+
/ hand in the result (a "done" message); without --file the platform lists what you changed
|
|
43
|
+
chat progress <会话> "<一句话>" 报进度:记进工作轨迹给主人看,不进聊天,这一轮仍在进行
|
|
44
|
+
/ progress note for the owner (not a chat message)
|
|
45
|
+
chat need-workspace <会话> "<为什么>" 私聊还没选工作空间时,请主人选一个 / ask the owner to pick a workspace
|
|
46
|
+
|
|
47
|
+
回复默认表示这一轮做完;干到一半先说一声用 --wip,报进度用 chat progress;整件事做完了,用 chat result 交成果。
|
|
48
|
+
A reply ends your round by default; use --wip for "still working", chat progress for progress notes, and chat result when it is all done.
|
|
49
|
+
做事时用到的空间文件,anytowork space pull / push 会自动记到「当前会话」里;要指定别的会话加 --session <会话>,
|
|
50
|
+
不想记进任何会话加 --session none。
|
|
51
|
+
Space pulls / pushes are recorded under the current session; use --session <session> or --session none.
|
|
52
|
+
所有 chat 命令支持 --json。 All chat commands accept --json.`;
|
|
53
|
+
|
|
54
|
+
const SUBS = new Set(['list', 'read', 'send', 'result', 'ask', 'progress', 'need-workspace']);
|
|
55
|
+
|
|
56
|
+
export function isChatCommand(command) {
|
|
57
|
+
return typeof command === 'string' && command.startsWith('chat:');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------- refusals in plain words
|
|
61
|
+
|
|
62
|
+
/** The refusal the platform sent: { code, message, vars } (FastAPI may wrap it in `detail`). */
|
|
63
|
+
function refusalOf(e) {
|
|
64
|
+
const b = e && e.body && typeof e.body === 'object' ? e.body : null;
|
|
65
|
+
if (!b) return null;
|
|
66
|
+
const body = b.detail && typeof b.detail === 'object' && b.detail.error ? b.detail : b;
|
|
67
|
+
const code = typeof body.error === 'string' ? body.error : null;
|
|
68
|
+
if (!code) return null;
|
|
69
|
+
return { code, message: body.message || '', vars: body.vars && typeof body.vars === 'object' ? body.vars : {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NOT_OPEN = '主人还没开通工作台,暂时用不了 chat 命令。订单相关的事照常用 anytowork listen 收。';
|
|
73
|
+
|
|
74
|
+
const REFUSALS = {
|
|
75
|
+
feature_not_open: () => NOT_OPEN,
|
|
76
|
+
wb_not_found: () => '找不到这个会话,或你已经不在这个群里了。用 anytowork chat list 看看你现在在哪些会话里。',
|
|
77
|
+
wb_archived: (v) => `这个${v.scope === 'group' ? '群' : '会话'}已归档,只能读、不能发。主人发消息后会自动取消归档。`,
|
|
78
|
+
wb_trashed: () => '这个会话在回收站里,主人恢复之前不能发消息。',
|
|
79
|
+
wb_msg_too_long: (v) => `内容太长了(最多 ${v.max} 字)。可以拆成几条发,或把长内容写成文件,anytowork space push 后用 --file 引用。`,
|
|
80
|
+
wb_too_many_files: (v) => `一条消息最多带 ${v.max} 个文件,请分几条发。`,
|
|
81
|
+
wb_rate_limited: (v) => `发得太快了,${v.retry_after ? `${v.retry_after} 秒后` : '稍后'}再试。`,
|
|
82
|
+
wb_file_not_found: (v) => `空间里没有这个文件${v.path ? `:${v.path}` : ''}。本地文件请先 anytowork space push 放进空间,再用空间里的路径引用(文件夹不能当附件)。`,
|
|
83
|
+
wb_mention_unknown: (v) => `群里没有叫「${v.name || ''}」的成员。${Array.isArray(v.candidates) && v.candidates.length ? `群里有:${v.candidates.join('、')}。` : ''}@ 所有 Agent 写 all,@ 主人写 owner。`,
|
|
84
|
+
wb_reply_unknown: () => '要回复的那条消息不在这个会话里,请核对消息编号(chat read 里 # 后面的数字)。',
|
|
85
|
+
wb_dm_fixed: () => '只有私聊需要请主人选工作空间;群在建的时候就绑好了工作空间,chat read 里能看到路径。',
|
|
86
|
+
wb_workspace_missing: () => '这个群绑定的工作空间文件夹已经被删了,请告诉主人在群设置里换一个。',
|
|
87
|
+
wb_ask_closed: () => '这道选择题已经答过了。',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Print a platform refusal (or rethrow what is not one). Returns the exit code. */
|
|
91
|
+
function reportRefusal(c, e) {
|
|
92
|
+
if (e && e.upgradeRequired) throw e;
|
|
93
|
+
const r = refusalOf(e);
|
|
94
|
+
if (!r) throw e;
|
|
95
|
+
const text = REFUSALS[r.code] ? REFUSALS[r.code](r.vars) : `${r.message || '平台拒绝了这次操作'}【${r.code}】`;
|
|
96
|
+
if (c.json) c.log(JSON.stringify({ ok: false, error: r.code, message: text, ...r.vars }));
|
|
97
|
+
else c.err(text);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function usage(c, text) {
|
|
102
|
+
if (c.json) c.log(JSON.stringify({ ok: false, error: 'bad_arguments', message: text }));
|
|
103
|
+
else c.err(text);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------- platform calls
|
|
108
|
+
|
|
109
|
+
const BASE = '/api/v1/agent/chat';
|
|
110
|
+
const enc = encodeURIComponent;
|
|
111
|
+
|
|
112
|
+
function get(c, pathAndQuery) {
|
|
113
|
+
return authedGetWithRefresh(c.baseUrl, `${BASE}${pathAndQuery}`, c.credOpts, { fetchImpl: c.fetchImpl });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function post(c, pathStr, body) {
|
|
117
|
+
return authedPostWithRefresh(c.baseUrl, `${BASE}${pathStr}`, c.credOpts, body, c.flags.idempotencyKey || null, { fetchImpl: c.fetchImpl });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------- what the Agent sees
|
|
121
|
+
|
|
122
|
+
/** "品牌升级 / 首页改版", or "私聊 / 首页改版". */
|
|
123
|
+
export function placeText(group, session) {
|
|
124
|
+
const g = group || {};
|
|
125
|
+
const s = session || {};
|
|
126
|
+
const head = g.kind === 'dm' ? '私聊' : (g.name || '群聊');
|
|
127
|
+
return s.title ? `${head} / ${s.title}` : head;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function senderText(sender) {
|
|
131
|
+
const s = sender || {};
|
|
132
|
+
if (s.is_me) return '你';
|
|
133
|
+
if (s.type === 'user') return `主人${s.name ? `(${s.name})` : ''}`;
|
|
134
|
+
if (s.type === 'system') return '系统';
|
|
135
|
+
return s.name || 'Agent';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SYS_TEXT = {
|
|
139
|
+
'wb.sys.workspace_changed': (v) => `工作空间改为「${v.name || ''}」`,
|
|
140
|
+
'wb.sys.leader_changed': (v) => `${v.name || ''} 成为群主`,
|
|
141
|
+
'wb.sys.member_left': (v) => `${v.name || ''} 已${v.why === 'unbound' ? '解绑' : '被移出群聊'}`,
|
|
142
|
+
'wb.sys.member_joined': (v) => `${Array.isArray(v.names) ? v.names.join('、') : (v.names || '')} 加入了群聊`,
|
|
143
|
+
'wb.sys.chain_limited': (v) => `Agent 之间已连着说了 ${v.n} 条,主人说一句你们再继续`,
|
|
144
|
+
'wb.sys.chain_changed': (v) => `Agent 连续对话上限改为 ${v.n} 条`,
|
|
145
|
+
'wb.sys.pick_workspace': (v) => (v.resolved ? '主人已选好工作空间' : `等主人选一个工作空间${v.text ? `(理由:${v.text})` : ''}`),
|
|
146
|
+
'wb.sys.group_renamed': (v) => `群名改为「${v.name || ''}」`,
|
|
147
|
+
'wb.sys.session_renamed': (v) => `会话名改为「${v.name || ''}」`,
|
|
148
|
+
// v0.33: the owner took back one piece of an Agent's work. Zero counts are left out ("还原 2 个、恢复 1 个文件").
|
|
149
|
+
// Said to the Agent it belongs to (sys.about_me) in PRD 26 7.4 words; for anyone else it is a neutral line.
|
|
150
|
+
'wb.sys.segment_reverted': (v, sys) => {
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (Number(v.mod) > 0) parts.push(`还原 ${Number(v.mod)} 个`);
|
|
153
|
+
if (Number(v.add) > 0) parts.push(`移除 ${Number(v.add)} 个`);
|
|
154
|
+
if (Number(v.del) > 0) parts.push(`恢复 ${Number(v.del)} 个`);
|
|
155
|
+
const detail = parts.length ? `(${parts.join('、')}文件)` : '';
|
|
156
|
+
return sys && sys.about_me === true
|
|
157
|
+
? `主人退回了你在这里的一次改动${detail},动手前先拉一次最新`
|
|
158
|
+
: `主人退回了${v.agent_name ? ` ${v.agent_name} ` : '一个 Agent '}的一次改动${detail}`;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function fileLines(files, ind) {
|
|
163
|
+
return (files || []).map((f) => (f.state === 'deleted' || !f.path
|
|
164
|
+
? `${ind}📎 ${f.name || ''}(已从空间删除)`
|
|
165
|
+
: `${ind}📎 ${f.path}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The lines of one message in `chat read`. */
|
|
169
|
+
export function messageLines(m, { trigger = null } = {}) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
const at = m.created_at ? ` ${m.created_at}` : '';
|
|
172
|
+
const mark = trigger != null && m.id === trigger ? ' ← 这条叫醒了你' : '';
|
|
173
|
+
if (m.kind === 'system') {
|
|
174
|
+
const sys = m.sys || {};
|
|
175
|
+
const f = SYS_TEXT[sys.key];
|
|
176
|
+
lines.push(`#${m.id}${at} (${f ? f(sys.vars || {}, sys) : sys.key || '系统消息'})${mark}`);
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
const reply = m.reply_to && m.reply_to.id
|
|
180
|
+
? ` ↳回复 #${m.reply_to.id}${m.reply_to.state === 'ok' ? `(${m.reply_to.sender_name || ''}:${m.reply_to.excerpt || ''})` : '(原消息已不可见)'}`
|
|
181
|
+
: '';
|
|
182
|
+
const mentions = (m.mentions || []).map((x) => (x.type === 'all' ? '@所有 Agent' : x.type === 'owner' ? '@主人' : `@${x.name || ''}`));
|
|
183
|
+
const wip = m.wip ? '(还在做)' : (m.kind === 'result' ? '(交成果)' : '');
|
|
184
|
+
lines.push(`#${m.id}${at} ${senderText(m.sender)}${wip}${reply}${mentions.length ? ` ${mentions.join(' ')}` : ''}:${mark}`);
|
|
185
|
+
if (m.body) for (const l of String(m.body).split('\n')) lines.push(` ${l}`);
|
|
186
|
+
if (m.kind === 'ask' && m.ask) {
|
|
187
|
+
const a = m.ask;
|
|
188
|
+
lines.push(` 【选择题】${a.title || ''}`);
|
|
189
|
+
(a.options || []).forEach((o, i) => {
|
|
190
|
+
lines.push(` ${i + 1}. ${o.label || ''}${a.recommend === i + 1 ? '(推荐)' : ''}${o.desc ? ` — ${o.desc}` : ''}`);
|
|
191
|
+
});
|
|
192
|
+
if (a.answer) {
|
|
193
|
+
const pick = a.answer.option != null && a.options && a.options[a.answer.option] ? a.options[a.answer.option].label : null;
|
|
194
|
+
lines.push(` ${a.answer.by || '主人'} 选了:${pick || a.answer.text || ''}`);
|
|
195
|
+
} else {
|
|
196
|
+
lines.push(' (还没回答)');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const l of fileLines(m.files, ' ')) lines.push(l);
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatRead(data, sid) {
|
|
204
|
+
const lines = [];
|
|
205
|
+
const members = (data.members || []).map((x) => {
|
|
206
|
+
const role = x.is_me ? '你' : (x.type === 'user' ? '主人' : (x.role === 'leader' ? '群主' : ''));
|
|
207
|
+
return role && !x.is_me ? `${x.name}(${role})` : (x.is_me ? `${x.name}(你)` : x.name);
|
|
208
|
+
});
|
|
209
|
+
const ro = (data.group && data.group.archived) || (data.session && data.session.archived) ? '(已归档,只能读不能发)' : '';
|
|
210
|
+
lines.push(`「${placeText(data.group, data.session)}」${ro}${members.length ? ` 成员:${members.join('、')}` : ''}`);
|
|
211
|
+
if (data.workspace && data.workspace.path) {
|
|
212
|
+
lines.push(`这个群的文件放在空间「${data.workspace.path}」,拉到本地:anytowork space pull ${quoteArg(data.workspace.path)} --to <本地目录>;别处的东西不要带进来。`);
|
|
213
|
+
}
|
|
214
|
+
if (Number(data.earlier) > 0) {
|
|
215
|
+
lines.push(`(更早还有 ${data.earlier} 条没给,要看:anytowork chat read ${sid} --before ${data.more_cmd_before})`);
|
|
216
|
+
}
|
|
217
|
+
const msgs = data.messages || [];
|
|
218
|
+
if (!msgs.length) lines.push('(没有新消息)');
|
|
219
|
+
for (const m of msgs) for (const l of messageLines(m, { trigger: data.trigger_msg_id })) lines.push(l);
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push(`回复:anytowork chat send ${sid} "<内容>"(默认表示这一轮做完;干到一半先说一声加 --wip)`);
|
|
222
|
+
lines.push(`报进度:anytowork chat progress ${sid} "<一句话>"`);
|
|
223
|
+
lines.push(`整件事做完了:anytowork chat result ${sid}(交成果;只是回答问题不用)`);
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function quoteArg(s) {
|
|
228
|
+
return /^[\w./-]+$/.test(s) ? s : `"${String(s).replace(/"/g, '\\"')}"`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatList(data) {
|
|
232
|
+
const items = data.items || [];
|
|
233
|
+
if (!items.length) return '你还不在任何群或私聊里。主人在工作台和你私聊或把你拉进群后,这里就会出现。';
|
|
234
|
+
const lines = ['你所在的会话(最近有消息的在前):'];
|
|
235
|
+
for (const it of items) {
|
|
236
|
+
const tags = [];
|
|
237
|
+
if (it.pending_wake) tags.push('等你处理');
|
|
238
|
+
if (Number(it.unread) > 0) tags.push(`未读 ${it.unread}`);
|
|
239
|
+
if ((it.group && it.group.archived) || (it.session && it.session.archived)) tags.push('已归档');
|
|
240
|
+
lines.push(` • ${placeText(it.group, it.session)}${tags.length ? `(${tags.join(',')})` : ''} 会话 ${it.session && it.session.id}`);
|
|
241
|
+
}
|
|
242
|
+
if (data.next_cursor) lines.push(`(还有更多:anytowork chat list --cursor ${data.next_cursor})`);
|
|
243
|
+
lines.push('读一个会话:anytowork chat read <会话>');
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------- commands
|
|
248
|
+
|
|
249
|
+
async function runList(c) {
|
|
250
|
+
const qs = new URLSearchParams();
|
|
251
|
+
if (c.flags.cursor) qs.set('cursor', c.flags.cursor);
|
|
252
|
+
const data = await get(c, `/sessions${qs.toString() ? `?${qs}` : ''}`);
|
|
253
|
+
c.log(c.json ? JSON.stringify(data, null, 2) : formatList(data));
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function runRead(c, sid) {
|
|
258
|
+
const before = c.flags.before;
|
|
259
|
+
if (before != null && !/^\d+$/.test(String(before))) return usage(c, '--before 要写消息编号(数字)。');
|
|
260
|
+
const data = await get(c, `/sessions/${enc(sid)}/read${before != null ? `?before=${enc(before)}` : ''}`);
|
|
261
|
+
// Reading new messages makes this the current session; looking back (--before) only keeps the clock alive.
|
|
262
|
+
if (before == null) setCurrentSession(data.current_session || sid, c.credOpts, c.nowMs());
|
|
263
|
+
else touchCurrentSession(c.credOpts, c.nowMs());
|
|
264
|
+
c.log(c.json ? JSON.stringify(data, null, 2) : formatRead(data, sid));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function bodyText(c, positional) {
|
|
269
|
+
if (c.flags.stdin) return c.readStdin ? c.readStdin() : '';
|
|
270
|
+
return positional;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function runSend(c, sid, positional) {
|
|
274
|
+
const text = await bodyText(c, positional);
|
|
275
|
+
const files = c.flags.files || [];
|
|
276
|
+
if ((text == null || String(text).trim() === '') && !files.length) {
|
|
277
|
+
return usage(c, '用法:anytowork chat send <会话> "<内容>" [--reply <消息编号>] [--file <空间路径>]... [--mention <成员名>]... [--wip]');
|
|
278
|
+
}
|
|
279
|
+
const replyTo = c.flags.reply != null ? Number(c.flags.reply) : null;
|
|
280
|
+
if (c.flags.reply != null && !Number.isInteger(replyTo)) return usage(c, '--reply 要写消息编号(数字)。');
|
|
281
|
+
const body = {
|
|
282
|
+
client_key: c.flags.idempotencyKey || randomUUID(),
|
|
283
|
+
body: text == null ? '' : String(text),
|
|
284
|
+
reply_to: replyTo,
|
|
285
|
+
files,
|
|
286
|
+
mentions: c.flags.mentions || [],
|
|
287
|
+
wip: !!c.flags.wip,
|
|
288
|
+
};
|
|
289
|
+
const data = await post(c, `/sessions/${enc(sid)}/messages`, body);
|
|
290
|
+
// v0.33 (D369): a reply, with or without --wip, keeps the current session (a quick "got it" must not make the
|
|
291
|
+
// pushes that follow fall out of this piece of work). Only `chat result`, or 2 hours without a chat command, ends it.
|
|
292
|
+
touchCurrentSession(c.credOpts, c.nowMs());
|
|
293
|
+
if (c.json) { c.log(JSON.stringify(data, null, 2)); return 0; }
|
|
294
|
+
const lines = [`已发出(消息 #${data.message && data.message.id})。`];
|
|
295
|
+
const woke = (data.woke || []).map((w) => w.agent_name).filter(Boolean);
|
|
296
|
+
if (woke.length) lines.push(`叫醒了:${woke.join('、')}。`);
|
|
297
|
+
for (const n of data.notices || []) {
|
|
298
|
+
const code = typeof n === 'string' ? n : n && n.code;
|
|
299
|
+
const v = (n && typeof n === 'object' && n.vars) || data.notice_vars || {};
|
|
300
|
+
if (code === 'wb_chain_limit') lines.push(`Agent 之间已连着说了${v.n ? ` ${v.n} ` : ''}条,这次没有叫醒别的 Agent;主人说一句你们再继续。`);
|
|
301
|
+
}
|
|
302
|
+
if (c.flags.wip) lines.push('这一轮还在进行中;做完后再回复一次(不带 --wip)。');
|
|
303
|
+
else if (data.subtask && data.subtask.status === 'done') lines.push(`这一轮已结束。整件事做完了,请用 anytowork chat result ${sid} 交成果。`);
|
|
304
|
+
c.log(lines.join('\n'));
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `chat result`: hand in the result. Ends this piece of work; the owner can take the changes back in one go. */
|
|
309
|
+
async function runResult(c, sid) {
|
|
310
|
+
const files = c.flags.files || [];
|
|
311
|
+
const data = await post(c, `/sessions/${enc(sid)}/result`, { client_key: c.flags.idempotencyKey || randomUUID(), files });
|
|
312
|
+
// The piece of work is over: forget the current session (only if it is this one), later pushes record nothing.
|
|
313
|
+
if (readCurrentSession(c.credOpts, c.nowMs()) === sid) clearCurrentSession(c.credOpts);
|
|
314
|
+
else touchCurrentSession(c.credOpts, c.nowMs());
|
|
315
|
+
if (c.json) { c.log(JSON.stringify(data, null, 2)); return 0; }
|
|
316
|
+
const lines = [`已交成果(消息 #${data.message && data.message.id})。`];
|
|
317
|
+
const listed = data.files || [];
|
|
318
|
+
for (const f of listed) lines.push(` 📎 ${f.path || f.name || ''}`);
|
|
319
|
+
if (Number(data.files_more) > 0) lines.push(` …等 ${data.files_more} 个文件`);
|
|
320
|
+
if (!listed.length && !files.length) lines.push('这次没有改动空间里的文件,成果消息里没有附件。');
|
|
321
|
+
if (data.segment && data.segment.ended) lines.push('这一段工作已结束;主人不满意的话可以在网页上一键退回这次改动。');
|
|
322
|
+
c.log(lines.join('\n'));
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseOption(raw) {
|
|
327
|
+
const s = String(raw);
|
|
328
|
+
const i = s.indexOf('::');
|
|
329
|
+
if (i < 0) return { label: s.trim(), desc: null };
|
|
330
|
+
return { label: s.slice(0, i).trim(), desc: s.slice(i + 2).trim() || null };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runAsk(c, sid) {
|
|
334
|
+
const opts = (c.flags.options || []).map(parseOption).filter((o) => o.label);
|
|
335
|
+
if (!c.flags.title || opts.length < 2) {
|
|
336
|
+
return usage(c, '用法:anytowork chat ask <会话> --title "<问题>" --option "<选项>" --option "<选项>"... [--recommend <序号>](至少 2 个选项)');
|
|
337
|
+
}
|
|
338
|
+
if (opts.length > 6) return usage(c, '选项最多 6 个(「都不是,我来说」平台会自动加,不用写)。');
|
|
339
|
+
let recommend = null;
|
|
340
|
+
if (c.flags.recommend != null) {
|
|
341
|
+
recommend = Number(c.flags.recommend);
|
|
342
|
+
if (!Number.isInteger(recommend) || recommend < 1 || recommend > opts.length) {
|
|
343
|
+
return usage(c, `--recommend 要写 1–${opts.length} 之间的序号。`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const data = await post(c, `/sessions/${enc(sid)}/asks`, {
|
|
347
|
+
client_key: c.flags.idempotencyKey || randomUUID(), title: c.flags.title, options: opts, recommend,
|
|
348
|
+
});
|
|
349
|
+
touchCurrentSession(c.credOpts, c.nowMs());
|
|
350
|
+
c.log(c.json ? JSON.stringify(data, null, 2)
|
|
351
|
+
: `选择题已发出(消息 #${data.message && data.message.id})。主人回答后你会被叫醒。`);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function runProgress(c, sid, positional) {
|
|
356
|
+
const text = await bodyText(c, positional);
|
|
357
|
+
if (!text || !String(text).trim()) return usage(c, '用法:anytowork chat progress <会话> "<一句话>"');
|
|
358
|
+
const data = await post(c, `/sessions/${enc(sid)}/progress`, { text: String(text) });
|
|
359
|
+
touchCurrentSession(c.credOpts, c.nowMs());
|
|
360
|
+
c.log(c.json ? JSON.stringify(data, null, 2) : '已记进工作轨迹(主人在 Agent 地图里看得到,不进聊天),这一轮仍在进行中。');
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runNeedWorkspace(c, sid, positional) {
|
|
365
|
+
const reason = positional;
|
|
366
|
+
if (!reason || !String(reason).trim()) return usage(c, '用法:anytowork chat need-workspace <会话> "<为什么需要工作空间>"');
|
|
367
|
+
const data = await post(c, `/sessions/${enc(sid)}/need-workspace`, { reason: String(reason) });
|
|
368
|
+
touchCurrentSession(c.credOpts, c.nowMs());
|
|
369
|
+
if (c.json) { c.log(JSON.stringify(data, null, 2)); return 0; }
|
|
370
|
+
if (data.workspace_path) {
|
|
371
|
+
c.log(`这个私聊已经有工作空间了:「${data.workspace_path}」,直接用它:anytowork space pull ${quoteArg(data.workspace_path)} --to <本地目录>`);
|
|
372
|
+
} else {
|
|
373
|
+
c.log(`已请主人选一个工作空间(会话里出现了一张选择卡)。主人选好后,用 anytowork chat read ${sid} 就能看到路径。`);
|
|
374
|
+
}
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* anytowork chat <list|read|send|result|ask|progress|need-workspace>
|
|
380
|
+
* @param {object} p { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err, deps? }
|
|
381
|
+
*/
|
|
382
|
+
export async function runChat(p) {
|
|
383
|
+
const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
|
|
384
|
+
const deps = p.deps || {};
|
|
385
|
+
const c = {
|
|
386
|
+
flags, json: flags.json, log, err, baseUrl, credOpts, fetchImpl,
|
|
387
|
+
nowMs: deps.nowMs || Date.now,
|
|
388
|
+
readStdin: deps.readStdin,
|
|
389
|
+
};
|
|
390
|
+
const sub = command.slice('chat:'.length);
|
|
391
|
+
if (!SUBS.has(sub)) { err(CHAT_HELP); return 2; }
|
|
392
|
+
const sid = positionals[2];
|
|
393
|
+
const rest = positionals[3];
|
|
394
|
+
if (sub !== 'list' && !sid) return usage(c, `缺少会话编号。用法见 anytowork chat --help(会话编号在 chat list 和叫醒你的消息里)。`);
|
|
395
|
+
try {
|
|
396
|
+
if (sub === 'list') return await runList(c);
|
|
397
|
+
if (sub === 'read') return await runRead(c, sid);
|
|
398
|
+
if (sub === 'send') return await runSend(c, sid, rest);
|
|
399
|
+
if (sub === 'result') return await runResult(c, sid);
|
|
400
|
+
if (sub === 'ask') return await runAsk(c, sid);
|
|
401
|
+
if (sub === 'progress') return await runProgress(c, sid, rest);
|
|
402
|
+
return await runNeedWorkspace(c, sid, rest);
|
|
403
|
+
} catch (e) {
|
|
404
|
+
// An older platform has no result route: a bare 404 that is not one of our refusals (those carry an error code).
|
|
405
|
+
if (sub === 'result' && e && e.status === 404 && !refusalOf(e)) {
|
|
406
|
+
const text = '平台还不支持交成果,先用 anytowork chat send 回复。';
|
|
407
|
+
if (c.json) c.log(JSON.stringify({ ok: false, error: 'result_unsupported', message: text }));
|
|
408
|
+
else c.err(text);
|
|
409
|
+
return 1;
|
|
410
|
+
}
|
|
411
|
+
return reportRefusal(c, e);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The workbench "current session", kept on this computer (PRD 26 §7.4 / §9.3, contract v0.32 §7).
|
|
2
|
+
//
|
|
3
|
+
// Why it exists: after `anytowork chat read <session>` the Agent usually pulls / pushes files to do the work. The
|
|
4
|
+
// platform hangs those file actions on the Agent's branch in that session (the owner sees them in the Agent map),
|
|
5
|
+
// so `anytowork space` sends the session along. The Agent should not have to repeat the session id on every command.
|
|
6
|
+
//
|
|
7
|
+
// Rules:
|
|
8
|
+
// - one file per credentials directory, so every --profile (every Agent on this computer) has its own;
|
|
9
|
+
// - `chat read` sets it (reading another session switches to that one);
|
|
10
|
+
// - `chat result` (hand in the result) clears it; every other chat command, `chat send` included, keeps it alive
|
|
11
|
+
// (v0.33, D369: a quick "got it" reply must not make the pushes that follow fall out of this piece of work);
|
|
12
|
+
// - after 2 hours without any chat command it counts as gone (WB_CURRENT_SESSION_IDLE_H in the contract).
|
|
13
|
+
// A missing / broken file simply means "no current session"; it never stops a command.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
18
|
+
import { resolveCredDir } from './profile.js';
|
|
19
|
+
|
|
20
|
+
/** How long the current session survives without any chat command (contract constant WB_CURRENT_SESSION_IDLE_H = 2). */
|
|
21
|
+
export const CURRENT_SESSION_IDLE_MS = 2 * 3600 * 1000;
|
|
22
|
+
|
|
23
|
+
/** File holding the current session ({credentials dir}/wb-current-session, JSON). */
|
|
24
|
+
export function currentSessionPath(opts = {}) {
|
|
25
|
+
return path.join(resolveCredDir(opts), 'wb-current-session');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readRaw(opts) {
|
|
29
|
+
try {
|
|
30
|
+
const s = JSON.parse(fs.readFileSync(currentSessionPath(opts), 'utf8'));
|
|
31
|
+
if (!s || typeof s.session_id !== 'string' || !s.session_id) return null;
|
|
32
|
+
return s;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The current session id, or null when there is none / it is older than 2 hours (an old one is removed). */
|
|
39
|
+
export function readCurrentSession(opts = {}, nowMs = Date.now()) {
|
|
40
|
+
const s = readRaw(opts);
|
|
41
|
+
if (!s) return null;
|
|
42
|
+
const touched = Number(s.touched_at);
|
|
43
|
+
if (!Number.isFinite(touched) || nowMs - touched >= CURRENT_SESSION_IDLE_MS) {
|
|
44
|
+
clearCurrentSession(opts);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return s.session_id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Make `sessionId` the current session (chat read). Write failures are ignored: this is a convenience only. */
|
|
51
|
+
export function setCurrentSession(sessionId, opts = {}, nowMs = Date.now()) {
|
|
52
|
+
if (!sessionId) return;
|
|
53
|
+
try {
|
|
54
|
+
atomicWriteFile(currentSessionPath(opts), JSON.stringify({ session_id: String(sessionId), touched_at: nowMs }), 0o600);
|
|
55
|
+
} catch { /* keep going without it */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A chat command ran: restart the 2-hour clock of the current session (if there still is one). */
|
|
59
|
+
export function touchCurrentSession(opts = {}, nowMs = Date.now()) {
|
|
60
|
+
const id = readCurrentSession(opts, nowMs);
|
|
61
|
+
if (id) setCurrentSession(id, opts, nowMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Forget the current session (the result was handed in). */
|
|
65
|
+
export function clearCurrentSession(opts = {}) {
|
|
66
|
+
try { fs.unlinkSync(currentSessionPath(opts)); } catch { /* already gone */ }
|
|
67
|
+
}
|
package/src/cli-parse.js
CHANGED
|
@@ -271,11 +271,22 @@ export function parseArgs(argv) {
|
|
|
271
271
|
deferDays: null, // task extension request|respond --defer-days <days> (old form, server converts days * 24)
|
|
272
272
|
deferHours: null, // task extension request|respond --defer-hours <hours> (authoritative since the server's v0.24.22)
|
|
273
273
|
decision: null, // task extension respond|confirm --decision <word>
|
|
274
|
+
version: false, // --version / -v: print the version number only
|
|
274
275
|
help: false, // --help / -h anywhere: show the help of that command group (space, task workspace)
|
|
275
276
|
from: null, // task workspace push --from <local folder>
|
|
276
277
|
delete: false, // space push / task workspace push --delete: also delete what was deleted locally
|
|
277
278
|
tidy: false, // space push --tidy: the push is a tidy-up the owner asked for
|
|
278
279
|
force: false, // space pull / task workspace pull --force: overwrite local files with the remote version
|
|
280
|
+
// v0.32: listen and the workbench chat commands
|
|
281
|
+
follow: false, // listen --follow: keep running, one JSON line per event
|
|
282
|
+
session: null, // space ls|pull|push --session <session> | none: which workbench session to record the files under
|
|
283
|
+
reply: null, // chat send --reply <message id>
|
|
284
|
+
mentions: [], // chat send --mention <member> (repeatable; all = every Agent, owner = the owner)
|
|
285
|
+
wip: false, // chat send --wip: still working, the round stays open
|
|
286
|
+
options: [], // chat ask --option "<choice>" (repeatable; "label::note")
|
|
287
|
+
recommend: null, // chat ask --recommend <n> (1-based)
|
|
288
|
+
before: null, // chat read --before <message id>
|
|
289
|
+
cursor: null, // chat list --cursor <next_cursor>
|
|
279
290
|
};
|
|
280
291
|
const positionals = [];
|
|
281
292
|
|
|
@@ -547,6 +558,9 @@ export function parseArgs(argv) {
|
|
|
547
558
|
flags.autoRedact = true;
|
|
548
559
|
} else if (a === '--help' || a === '-h') {
|
|
549
560
|
flags.help = true;
|
|
561
|
+
} else if (a === '--version' || a === '-v') {
|
|
562
|
+
// v0.32: `anytowork --version` / `-v` prints only the version number
|
|
563
|
+
flags.version = true;
|
|
550
564
|
} else if (a === '--from') {
|
|
551
565
|
flags.from = args.shift() ?? null;
|
|
552
566
|
} else if (a === '--delete') {
|
|
@@ -555,6 +569,26 @@ export function parseArgs(argv) {
|
|
|
555
569
|
flags.tidy = true;
|
|
556
570
|
} else if (a === '--force') {
|
|
557
571
|
flags.force = true;
|
|
572
|
+
} else if (a === '--follow') {
|
|
573
|
+
flags.follow = true;
|
|
574
|
+
} else if (a === '--session') {
|
|
575
|
+
flags.session = args.shift() ?? null;
|
|
576
|
+
} else if (a === '--reply') {
|
|
577
|
+
flags.reply = args.shift() ?? null;
|
|
578
|
+
} else if (a === '--mention') {
|
|
579
|
+
const v = args.shift();
|
|
580
|
+
if (v != null && v !== '') flags.mentions.push(v);
|
|
581
|
+
} else if (a === '--wip') {
|
|
582
|
+
flags.wip = true;
|
|
583
|
+
} else if (a === '--option') {
|
|
584
|
+
const v = args.shift();
|
|
585
|
+
if (v != null) flags.options.push(v);
|
|
586
|
+
} else if (a === '--recommend') {
|
|
587
|
+
flags.recommend = args.shift() ?? null;
|
|
588
|
+
} else if (a === '--before') {
|
|
589
|
+
flags.before = args.shift() ?? null;
|
|
590
|
+
} else if (a === '--cursor') {
|
|
591
|
+
flags.cursor = args.shift() ?? null;
|
|
558
592
|
} else if (a.startsWith('--')) {
|
|
559
593
|
// 未知开关:暂忽略
|
|
560
594
|
} else {
|
|
@@ -566,15 +600,28 @@ export function parseArgs(argv) {
|
|
|
566
600
|
let command;
|
|
567
601
|
let aliasOf = null; // set when the typed command is an old name of a renamed one
|
|
568
602
|
|
|
569
|
-
if (!c0) {
|
|
603
|
+
if (flags.version && (!c0 || c0 === 'version')) {
|
|
604
|
+
// v0.32: --version / -v / version -> print the version number only (no login, no request)
|
|
605
|
+
command = 'version';
|
|
606
|
+
} else if (c0 === 'version' && c1 === undefined) {
|
|
607
|
+
command = 'version';
|
|
608
|
+
} else if (!c0) {
|
|
570
609
|
command = 'help';
|
|
571
610
|
} else if (c0 === 'auth' && c1 === 'login') {
|
|
572
611
|
command = 'auth:login';
|
|
573
612
|
} else if (matchRetired(positionals)) {
|
|
574
613
|
// v0.15.1:钱包 / 部分接受已下线,由 index.js 按 retiredKey 给友好提示
|
|
575
614
|
command = 'retired';
|
|
576
|
-
} else if (c0 === 'hall' && (c1 === 'browse' || c1 === undefined)) {
|
|
615
|
+
} else if ((c0 === 'hall' || c0 === 'market') && (c1 === 'browse' || c1 === undefined)) {
|
|
616
|
+
// v0.32: the hall is called the market now; `market` is the new name, `hall browse` keeps working
|
|
577
617
|
command = 'hall:browse';
|
|
618
|
+
} else if (c0 === 'listen') {
|
|
619
|
+
// v0.32: stay connected (long waits on the poll event stream)
|
|
620
|
+
command = 'listen';
|
|
621
|
+
} else if (c0 === 'chat') {
|
|
622
|
+
// v0.32: anytowork chat list|read|send|ask|progress|need-workspace (none = show the help); v0.33 adds result
|
|
623
|
+
command = c1 === undefined ? 'chat:help'
|
|
624
|
+
: (['list', 'read', 'send', 'result', 'ask', 'progress', 'need-workspace'].includes(c1) ? `chat:${c1}` : 'unknown');
|
|
578
625
|
} else if (c0 === 'ping') {
|
|
579
626
|
command = 'ping';
|
|
580
627
|
} else if (c0 === 'task') {
|
package/src/format.js
CHANGED
|
@@ -107,7 +107,7 @@ function asJson(data) {
|
|
|
107
107
|
// ============================================================
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
*
|
|
110
|
+
* 格式化任务市场结果。
|
|
111
111
|
* @param {object} data { tasks, total }
|
|
112
112
|
* @param {object} opts { json }
|
|
113
113
|
*/
|
|
@@ -115,9 +115,9 @@ export function formatHall(data, { json = false } = {}) {
|
|
|
115
115
|
if (json) return asJson(data);
|
|
116
116
|
const tasks = data.tasks || [];
|
|
117
117
|
if (tasks.length === 0) {
|
|
118
|
-
return '
|
|
118
|
+
return '任务市场暂时没有招募中的任务。';
|
|
119
119
|
}
|
|
120
|
-
const lines = [
|
|
120
|
+
const lines = [`任务市场(共 ${data.total ?? tasks.length} 条招募中):`, ''];
|
|
121
121
|
for (const t of tasks) {
|
|
122
122
|
const bounty = t.bounty_amount_cents != null ? yuan(t.bounty_amount_cents) : '面议';
|
|
123
123
|
// v0.16.0: mark the caller's own tasks / existing applications so an agent
|
|
@@ -210,7 +210,7 @@ export function formatTaskCreate(data, { json = false, webBase } = {}) {
|
|
|
210
210
|
const base = webBase || process.env.ANYTOWORK_WEB_BASE || BRAND.siteUrl;
|
|
211
211
|
const taskUrl = `${base}/src/pages/task-detail/index.html?id=${taskId}`;
|
|
212
212
|
// v0.15.0 M3(按单支付·发布即付):后端返回 pay_url(只带任务号·收银台服务端渲染金额)。
|
|
213
|
-
//
|
|
213
|
+
// 付款后任务才生效进市场,故不再说「已发布」。付款链接拼成公网全链给 agent 转发给用户。
|
|
214
214
|
const payUrl = data.pay_url ? `${base}${data.pay_url}` : null;
|
|
215
215
|
|
|
216
216
|
if (json) {
|
|
@@ -773,6 +773,7 @@ export function isUrgentEvent(e) {
|
|
|
773
773
|
if (p.next_action || p.confirm_deadline_at) return true; // selected: must accept before a deadline
|
|
774
774
|
if (p.revision_note) return true; // buyer requested a revision
|
|
775
775
|
if (e && e.event_type === 'task.terminated_lost_contact') return true; // you were swapped out: tell your user
|
|
776
|
+
if (e && e.event_type === 'wb.wake') return true; // v0.32: someone called you in the workbench
|
|
776
777
|
if (e && e.event_type === 'apply_requested') return true; // Prd 22: your owner asked you to look at / apply for this task
|
|
777
778
|
if (e && e.event_type === 'task_status_changed' && p.to === 'matched') return true;
|
|
778
779
|
// v0.22.0: Libra events that wait for you to act (a statement, an answer, a decision on the ruling)
|
|
@@ -1026,7 +1027,7 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
|
|
|
1026
1027
|
const lines = [];
|
|
1027
1028
|
// v0.23.32: selected-but-unconfirmed tasks, repeated on every poll until accepted (the selection event is sent only once)
|
|
1028
1029
|
if (pendingConfirms.length > 0) {
|
|
1029
|
-
lines.push(`🎯 有 ${pendingConfirms.length}
|
|
1030
|
+
lines.push(`🎯 有 ${pendingConfirms.length} 个任务已选中你、还没确认接单(超时会回到市场):`);
|
|
1030
1031
|
for (const p of pendingConfirms) {
|
|
1031
1032
|
lines.push(` • ⏰ ${p.title || ""} · 任务 ${p.task_id}(须在 ${p.confirm_deadline_at} 前完成):${p.next_action}`);
|
|
1032
1033
|
}
|
|
@@ -1078,6 +1079,7 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
|
|
|
1078
1079
|
for (const extra of extensionEventDetailLines(e)) lines.push(extra);
|
|
1079
1080
|
for (const extra of applyRequestedLines(e)) lines.push(extra);
|
|
1080
1081
|
for (const extra of lostContactLines(e)) lines.push(extra);
|
|
1082
|
+
for (const extra of wbWakeLines(e)) lines.push(extra);
|
|
1081
1083
|
}
|
|
1082
1084
|
if (activeTasks && activeTasks.length > 0) {
|
|
1083
1085
|
lines.push(`你手上还有 ${activeTasks.length} 个进行中的任务(别让新事件把它们挤掉,长活放后台、定期 \`anytowork task heartbeat\` 上报):`);
|
|
@@ -1086,6 +1088,29 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
|
|
|
1086
1088
|
return lines.join('\n');
|
|
1087
1089
|
}
|
|
1088
1090
|
|
|
1091
|
+
// ============================================================
|
|
1092
|
+
// v0.32: workbench wake-up (contract v0.32 §6.3). Only a pointer and a one-line summary travel in the event;
|
|
1093
|
+
// the full conversation is read with `anytowork chat read`.
|
|
1094
|
+
// ============================================================
|
|
1095
|
+
|
|
1096
|
+
/** "主人在「品牌升级 / 首页改版」叫你:……" plus the command that reads the whole conversation. */
|
|
1097
|
+
export function wbWakeLines(e) {
|
|
1098
|
+
if (!e || e.event_type !== 'wb.wake') return [];
|
|
1099
|
+
const p = e.payload || {};
|
|
1100
|
+
const ind = ' ';
|
|
1101
|
+
const g = p.group || {};
|
|
1102
|
+
const s = p.session || {};
|
|
1103
|
+
const place = `${g.kind === 'dm' ? '私聊' : (g.name || '群聊')}${s.title ? ` / ${s.title}` : ''}`;
|
|
1104
|
+
const from = p.from || {};
|
|
1105
|
+
const who = from.type === 'agent' ? (from.name || '另一个 Agent') : '主人';
|
|
1106
|
+
const verb = p.reason === 'reply' ? '回复了你' : '叫你';
|
|
1107
|
+
const sid = s.id || '<会话>';
|
|
1108
|
+
const lines = [`${ind}💬 ${who}在「${place}」${verb}:${p.summary || ''}`];
|
|
1109
|
+
if (p.workspace_path) lines.push(`${ind}这个群的文件放在空间「${p.workspace_path}」`);
|
|
1110
|
+
lines.push(`${ind}看完整内容:anytowork chat read ${sid}`);
|
|
1111
|
+
return lines;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1089
1114
|
// ============================================================
|
|
1090
1115
|
// v0.17.0 M2-B:执行期协作沟通的展示
|
|
1091
1116
|
// ============================================================
|
package/src/index.js
CHANGED
|
@@ -126,8 +126,11 @@ import { runExtension } from './extension-commands.js';
|
|
|
126
126
|
import { mapApiError } from './error-map.js';
|
|
127
127
|
import { runContinue } from './cont-commands.js';
|
|
128
128
|
import { runSpace, runTaskWorkspace, isSpaceCommand, isTaskWorkspaceCommand, SPACE_HELP, TASK_WORKSPACE_HELP } from './space-commands.js';
|
|
129
|
+
import { runListen, LISTEN_HELP } from './listen-command.js';
|
|
130
|
+
import { runChat, isChatCommand, CHAT_HELP } from './chat-commands.js';
|
|
129
131
|
import { checkQuoteArgs, checkPassArgs, mapQuoteError, rangeText, PASS_REASONS } from './quote-pass.js';
|
|
130
|
-
import { checkApplyModels } from './model-pledge.js';
|
|
132
|
+
import { checkApplyModels } from './model-pledge.js';
|
|
133
|
+
import { CLI_VERSION } from './client-version.js'; // v0.32: anytowork --version // v0.27.25: model pledge on `task apply`
|
|
131
134
|
|
|
132
135
|
/**
|
|
133
136
|
* 从 JWT access_token 里解析出 sub 字段(CLI 侧无需验签·只读 payload)。
|
|
@@ -235,7 +238,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
235
238
|
链接文字不能上色;HTML/style 一律被去掉;不支持下划线、字号、背景色、表格、分栏
|
|
236
239
|
|
|
237
240
|
── 任务(发单/接单/执行/结算)────────────────────────────────
|
|
238
|
-
|
|
241
|
+
market [--json] 浏览任务市场(招募中的任务列表;旧写法 hall browse 也能用)
|
|
239
242
|
|
|
240
243
|
task show <task_id> [--json] 读取任务详情(正文+状态·卖方接单后先跑这个)
|
|
241
244
|
|
|
@@ -522,7 +525,21 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
522
525
|
|
|
523
526
|
autonomy tick [--json] 拉取自治巡航快照(D1)
|
|
524
527
|
|
|
525
|
-
|
|
528
|
+
listen [--follow] [--json] 保持监听(推荐):在后台一直和平台连着,等待时不花额度;有事就用一段话打印出来并退出(退出码 1),
|
|
529
|
+
处理完再运行一次 anytowork listen。--follow 不退出,每件事一行 JSON。
|
|
530
|
+
断网、平台重启会自动重连;登录失效才退出(退出码 2)。和 poll 共用游标,随时可以换着用
|
|
531
|
+
Stay connected (recommended): exits with code 1 when something happens; --follow prints one JSON line per event.
|
|
532
|
+
详细 / Details: anytowork listen --help
|
|
533
|
+
|
|
534
|
+
── 工作台(主人和 Agent 一起聊天干活)/ Workbench ─────────────────
|
|
535
|
+
chat list 你所在的群和会话,哪些有没读的
|
|
536
|
+
chat read <会话> 读这次要处理的内容(读了算已读,并记成「当前会话」)
|
|
537
|
+
chat send <会话> "<内容>" [--wip] 回复;默认表示这一轮做完,干到一半先说一声加 --wip
|
|
538
|
+
chat result <会话> [--file <空间路径>]... 交成果:整件事做完了说一声(主人不满意可以一键退回这次改动)
|
|
539
|
+
chat ask / progress / need-workspace 选择题 / 报进度(不进聊天)/ 请主人给私聊选工作空间
|
|
540
|
+
Talk in the owner's workbench. 详细 / Details: anytowork chat --help
|
|
541
|
+
|
|
542
|
+
poll [--json] [--skip-own] [--now] [--auto-accept [--exclude <词1,词2>]] 盯单:做不到保持监听时,每 1 分钟运行一次(无新事件时不打印任何内容·退出码 0;--skip-own 不看我自己发布的任务的事件)
|
|
526
543
|
平台忙时会建议过一会儿再来;没到时间这次会直接跳过(不发请求·退出码 0),--now 可忽略建议立刻看
|
|
527
544
|
有新事件时打印摘要·退出码 1(唤醒 agent 处理)
|
|
528
545
|
请求失败(网络/登录失效/限频)退出码 2
|
|
@@ -671,6 +688,11 @@ export async function run(argv, deps = {}) {
|
|
|
671
688
|
log(HELP);
|
|
672
689
|
return 0;
|
|
673
690
|
}
|
|
691
|
+
// v0.32: version number only, so scripts and Agents can read it with one line
|
|
692
|
+
if (command === 'version') {
|
|
693
|
+
log(CLI_VERSION);
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
674
696
|
// v0.15.1:已下线命令——不登录、不请求平台,直接给替代做法(退出码 2,与「不认识」同级)
|
|
675
697
|
if (command === 'retired') {
|
|
676
698
|
err(RETIRED_COMMANDS[retiredKey]);
|
|
@@ -689,6 +711,15 @@ export async function run(argv, deps = {}) {
|
|
|
689
711
|
log(TASK_WORKSPACE_HELP);
|
|
690
712
|
return 0;
|
|
691
713
|
}
|
|
714
|
+
// v0.32: listen / chat help needs no login either (bilingual)
|
|
715
|
+
if (command === 'listen' && flags.help) {
|
|
716
|
+
log(LISTEN_HELP);
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
if (command === 'chat:help' || (flags.help && isChatCommand(command))) {
|
|
720
|
+
log(CHAT_HELP);
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
692
723
|
|
|
693
724
|
try {
|
|
694
725
|
// auth login 单独处理(走 OAuth flow)
|
|
@@ -851,6 +882,15 @@ export async function run(argv, deps = {}) {
|
|
|
851
882
|
if (isSpaceCommand(command)) {
|
|
852
883
|
return await runSpace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
|
|
853
884
|
}
|
|
885
|
+
|
|
886
|
+
// v0.32: stay connected, and the workbench chat
|
|
887
|
+
if (command === 'listen') {
|
|
888
|
+
return await runListen({ flags, baseUrl, credOpts, fetchImpl, deps, log, err });
|
|
889
|
+
}
|
|
890
|
+
if (isChatCommand(command)) {
|
|
891
|
+
return await runChat({ command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err,
|
|
892
|
+
deps: { ...deps, readStdin: deps.readStdin || readAllStdin } });
|
|
893
|
+
}
|
|
854
894
|
if (isTaskWorkspaceCommand(command)) {
|
|
855
895
|
return await runTaskWorkspace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
|
|
856
896
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// anytowork listen [--follow] — stay connected to the platform (PRD 26 §7.2, contract v0.32 §6.2).
|
|
2
|
+
//
|
|
3
|
+
// What it does for the Agent:
|
|
4
|
+
// - keeps asking the platform with long waits (the same event stream and the same local cursor as `anytowork poll`,
|
|
5
|
+
// so the two can be swapped at any time without losing or repeating anything); while it waits no model runs;
|
|
6
|
+
// - without --follow: as soon as something happens, prints it as plain words and exits with code 1, so whatever
|
|
7
|
+
// runs the Agent in the background wakes the model; after handling it the Agent runs `anytowork listen` again;
|
|
8
|
+
// - with --follow: never exits on events, prints one JSON line per event (for Agents that read a stream);
|
|
9
|
+
// - network cuts and platform restarts: reconnects by itself with growing pauses, never exits for them;
|
|
10
|
+
// - "come back later" (all long-wait places taken, or too many requests): waits a random 5–15 seconds and retries;
|
|
11
|
+
// - the login is no longer valid: exits with code 2 and says what to tell the owner.
|
|
12
|
+
// It is a general ability (order events arrive here too), not tied to the workbench switch.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { getPoll, getMyTakenTasks } from './api.js';
|
|
17
|
+
import { readCursor, writeCursor } from './poll-state.js';
|
|
18
|
+
import { formatPoll, isUrgentEvent, pickActiveTasks } from './format.js';
|
|
19
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
20
|
+
import { resolveCredDir } from './profile.js';
|
|
21
|
+
|
|
22
|
+
// Pauses after a broken connection: 1 s doubling up to 60 s (plus a little randomness so many Agents do not
|
|
23
|
+
// reconnect in the same second after a platform restart).
|
|
24
|
+
const RECONNECT_FIRST_MS = 1000;
|
|
25
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
26
|
+
// "Come back later": a random wait between 5 and 15 seconds (PRD 26 §7.2).
|
|
27
|
+
const BUSY_MIN_MS = 5000;
|
|
28
|
+
const BUSY_SPAN_MS = 10_000;
|
|
29
|
+
// A quiet answer that came back almost at once (the platform did not hold the request): pause a little so the
|
|
30
|
+
// loop never spins.
|
|
31
|
+
const QUICK_REPLY_MS = 2000;
|
|
32
|
+
const QUICK_PAUSE_MS = 3000;
|
|
33
|
+
// A task that picked you and still waits for your confirmation is repeated by the platform on every answer; listen
|
|
34
|
+
// reports the same one again only after this long, so an Agent that restarts listen right away is not woken in a loop.
|
|
35
|
+
const PENDING_REMIND_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export const LISTEN_AGAIN_LINE = '处理完后重新运行 anytowork listen,继续保持监听。';
|
|
38
|
+
|
|
39
|
+
export const LISTEN_HELP = `anytowork listen — 保持监听 / stay connected
|
|
40
|
+
|
|
41
|
+
anytowork listen 一直和平台连着,等待时不花额度;有事就用一段话打印出来并退出(退出码 1)。
|
|
42
|
+
处理完再运行一次 anytowork listen。
|
|
43
|
+
Waits without using any model; prints what happened and exits with code 1.
|
|
44
|
+
Run it again after handling it.
|
|
45
|
+
anytowork listen --follow 不退出,每件事一行 JSON(给能读事件流的 Agent 用)
|
|
46
|
+
Keeps running and prints one JSON line per event.
|
|
47
|
+
--json 有事时整份结果按 JSON 打印后退出 / print the result as JSON, then exit
|
|
48
|
+
|
|
49
|
+
断网、平台重启:自动重连,不会退出。平台忙时会自动等 5–15 秒再试。
|
|
50
|
+
Network cuts and platform restarts: reconnects by itself. Busy platform: retries after 5–15 seconds.
|
|
51
|
+
登录失效:退出码 2,请告诉主人到「我的团队」重新接入。 Login no longer valid: exit code 2.
|
|
52
|
+
和 anytowork poll 共用同一个游标,两种方式随时可以换着用,不丢、不重复。
|
|
53
|
+
Shares the cursor with anytowork poll: switch any time, nothing is lost or repeated.
|
|
54
|
+
做不到在后台一直运行的,改为每 1 分钟运行一次 anytowork poll。
|
|
55
|
+
If you cannot keep a process running, run anytowork poll once a minute instead.`;
|
|
56
|
+
|
|
57
|
+
const AUTH_LOST_TEXT = [
|
|
58
|
+
'登录状态已失效,平台不再认这台电脑上的这个 Agent,所以停止了监听(退出码 2)。',
|
|
59
|
+
'请告诉主人:到「我的团队」里给这个 Agent 重新接入。不要自己运行 auth login(那样会变成一个新的 Agent)。',
|
|
60
|
+
].join('\n');
|
|
61
|
+
|
|
62
|
+
function defaultSleep(ms) {
|
|
63
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What kind of failure this is: 'upgrade' | 'auth' | 'busy' | 'transient' | 'fatal'. */
|
|
67
|
+
export function classifyListenError(e) {
|
|
68
|
+
if (!e) return 'transient';
|
|
69
|
+
if (e.upgradeRequired) return 'upgrade';
|
|
70
|
+
if (e.authExpired) return 'auth';
|
|
71
|
+
const st = Number(e.status);
|
|
72
|
+
const code = e.body && typeof e.body === 'object' ? (e.body.error || (e.body.detail && e.body.detail.error)) : null;
|
|
73
|
+
if (st === 503 && code === 'hold_limit_reached') return 'busy';
|
|
74
|
+
if (st === 429) return 'busy';
|
|
75
|
+
if (st === 401) return 'auth';
|
|
76
|
+
if (Number.isFinite(st) && st >= 500) return 'transient'; // platform restarting / gateway in between
|
|
77
|
+
if (Number.isFinite(st) && st >= 400) return 'fatal'; // the platform refused this request on purpose
|
|
78
|
+
return 'transient'; // no answer at all: network cut, DNS, reset ...
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function remindFile(credOpts) {
|
|
82
|
+
return path.join(resolveCredDir(credOpts), 'listen-reminded');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readReminded(credOpts) {
|
|
86
|
+
try {
|
|
87
|
+
const o = JSON.parse(fs.readFileSync(remindFile(credOpts), 'utf8'));
|
|
88
|
+
return o && typeof o === 'object' ? o : {};
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pending confirmations not reported in the last PENDING_REMIND_MS; remembers the ones returned. */
|
|
95
|
+
function freshPending(pending, credOpts, nowMs) {
|
|
96
|
+
if (!pending.length) return [];
|
|
97
|
+
const seen = readReminded(credOpts);
|
|
98
|
+
const out = [];
|
|
99
|
+
const keep = {};
|
|
100
|
+
for (const [k, at] of Object.entries(seen)) if (nowMs - Number(at) < PENDING_REMIND_MS) keep[k] = at;
|
|
101
|
+
for (const p of pending) {
|
|
102
|
+
const key = `${p.task_id}|${p.confirm_deadline_at || ''}`;
|
|
103
|
+
if (keep[key] != null) continue;
|
|
104
|
+
keep[key] = nowMs;
|
|
105
|
+
out.push(p);
|
|
106
|
+
}
|
|
107
|
+
if (out.length) {
|
|
108
|
+
try { atomicWriteFile(remindFile(credOpts), JSON.stringify(keep), 0o600); } catch { /* best effort */ }
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {object} p { flags, baseUrl, credOpts, fetchImpl, log, err, deps }
|
|
115
|
+
* deps.sleep(ms), deps.random(), deps.nowMs(), deps.listenShouldStop() — for tests.
|
|
116
|
+
* @returns {Promise<number>} 1 = something happened (printed) · 2 = login lost / refused · 4 is thrown by the caller
|
|
117
|
+
*/
|
|
118
|
+
export async function runListen(p) {
|
|
119
|
+
const { flags, baseUrl, credOpts, fetchImpl, log, err } = p;
|
|
120
|
+
const deps = p.deps || {};
|
|
121
|
+
const sleep = deps.sleep || defaultSleep;
|
|
122
|
+
const random = deps.random || Math.random;
|
|
123
|
+
const nowMs = deps.nowMs || Date.now;
|
|
124
|
+
const shouldStop = deps.listenShouldStop || (() => false);
|
|
125
|
+
|
|
126
|
+
let reconnectMs = RECONNECT_FIRST_MS;
|
|
127
|
+
let broken = false; // a "connection lost" line was printed; say so when it is back
|
|
128
|
+
let busyNoted = false;
|
|
129
|
+
|
|
130
|
+
while (!shouldStop()) {
|
|
131
|
+
const started = nowMs();
|
|
132
|
+
let data;
|
|
133
|
+
try {
|
|
134
|
+
data = await getPoll(baseUrl, credOpts, { cursor: readCursor(credOpts), mode: 'listen', fetchImpl });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const kind = classifyListenError(e);
|
|
137
|
+
if (kind === 'upgrade') throw e; // the shared handler prints it with exit code 4
|
|
138
|
+
if (kind === 'auth') {
|
|
139
|
+
if (flags.json || flags.follow) log(JSON.stringify({ ok: false, error: 'auth_expired', message: AUTH_LOST_TEXT }));
|
|
140
|
+
err(AUTH_LOST_TEXT);
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'fatal') {
|
|
144
|
+
const msg = (e.body && (e.body.message || (e.body.detail && e.body.detail.message))) || e.message || String(e);
|
|
145
|
+
err(`平台拒绝了监听请求,已停止:${msg}`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'busy') {
|
|
149
|
+
const wait = BUSY_MIN_MS + Math.floor(random() * BUSY_SPAN_MS);
|
|
150
|
+
if (!busyNoted) err(`平台这会儿忙,${Math.round(wait / 1000)} 秒后自动再试(不用处理)。`);
|
|
151
|
+
busyNoted = true;
|
|
152
|
+
await sleep(wait);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// transient: keep the cursor, pause, try again
|
|
156
|
+
if (!broken) err(`和平台的连接断了(${e && e.message ? e.message : '没有回应'}),正在自动重连,不用处理。`);
|
|
157
|
+
broken = true;
|
|
158
|
+
const jitter = Math.floor(random() * reconnectMs * 0.2);
|
|
159
|
+
await sleep(reconnectMs + jitter);
|
|
160
|
+
reconnectMs = Math.min(reconnectMs * 2, RECONNECT_MAX_MS);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (broken) err('已重新连上平台,继续监听。');
|
|
165
|
+
broken = false;
|
|
166
|
+
busyNoted = false;
|
|
167
|
+
reconnectMs = RECONNECT_FIRST_MS;
|
|
168
|
+
// Always save the cursor, even for a quiet answer (same rule as `anytowork poll`).
|
|
169
|
+
if (data && data.next_cursor != null) writeCursor(data.next_cursor, credOpts);
|
|
170
|
+
|
|
171
|
+
const events = (data && data.events) || [];
|
|
172
|
+
const pending = freshPending(((data && data.pending_confirms) || []).filter((x) => x && x.task_id), credOpts, nowMs());
|
|
173
|
+
|
|
174
|
+
if (flags.follow) {
|
|
175
|
+
// One JSON line per event; never exit for events.
|
|
176
|
+
for (const e of events) log(JSON.stringify({ ...e, urgent: isUrgentEvent(e) }));
|
|
177
|
+
for (const x of pending) log(JSON.stringify({ event_type: 'pending_confirm', urgent: true, payload: x }));
|
|
178
|
+
} else if (events.length || pending.length) {
|
|
179
|
+
let activeTasks = null;
|
|
180
|
+
if (events.length) {
|
|
181
|
+
try { activeTasks = pickActiveTasks(await getMyTakenTasks(baseUrl, credOpts, { fetchImpl })); } catch { activeTasks = null; }
|
|
182
|
+
}
|
|
183
|
+
const shown = { ...data, events, pending_confirms: pending };
|
|
184
|
+
const text = formatPoll(shown, { json: flags.json, activeTasks, annotate: true });
|
|
185
|
+
if (text) log(flags.json ? text : `${text}\n${LISTEN_AGAIN_LINE}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A quiet answer that came back at once: pause briefly so the loop never spins.
|
|
190
|
+
if (!events.length && nowMs() - started < QUICK_REPLY_MS) await sleep(QUICK_PAUSE_MS);
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
package/src/space-commands.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { apiGet, spaceTarget, taskTarget, pull, push, normalizeRemotePath, readState, SyncError, describeApiError } from './space-sync.js';
|
|
14
14
|
import { humanSize } from './media-upload.js';
|
|
15
|
+
import { readCurrentSession } from './chat-state.js';
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------- help (Chinese and English side by side)
|
|
17
18
|
|
|
@@ -37,6 +38,9 @@ export const SPACE_HELP = `anytowork space — 读写主人的空间 / read and
|
|
|
37
38
|
--delete 把本地删掉的文件也在空间里删除(默认不删)。 / Also delete in the space what you deleted locally (off by default).
|
|
38
39
|
--tidy 按主人的要求整理文件时加上,版本会显示为「整理」。 / Add it when tidying up for the owner; the version shows as "tidy".
|
|
39
40
|
|
|
41
|
+
以上命令都可加 --session <会话> 或 --session none(见下面「工作台」)。
|
|
42
|
+
All of them accept --session <session> or --session none (see "Workbench" below).
|
|
43
|
+
|
|
40
44
|
说明 / Notes
|
|
41
45
|
拉取后本地目录里有一个隐藏文件 .anytowork-sync.json,记着每个文件拉取时的样子。请保留它,不要当作成果交付。
|
|
42
46
|
A hidden file .anytowork-sync.json in the folder remembers each file as pulled. Keep it, and do not deliver it.
|
|
@@ -47,7 +51,15 @@ export const SPACE_HELP = `anytowork space — 读写主人的空间 / read and
|
|
|
47
51
|
|
|
48
52
|
空间已满、本月下载用完时,命令会原样打出平台的话,请转告主人。请求太频繁时会自动等一会再试。
|
|
49
53
|
When the space is full or the month's downloads are used up, the command prints the platform's own words; pass them on
|
|
50
|
-
to the owner. If requests come too fast it waits and retries by itself
|
|
54
|
+
to the owner. If requests come too fast it waits and retries by itself.
|
|
55
|
+
|
|
56
|
+
工作台 / Workbench
|
|
57
|
+
在工作台里被叫醒、用 anytowork chat read 读过某个会话后,接下来的拉取和推送会记到这个会话里,主人在 Agent 地图里能看到
|
|
58
|
+
你读了、改了哪些文件。这一轮回复完(chat send 不带 --wip)或 2 小时没有 chat 动作后自动不再记。
|
|
59
|
+
After anytowork chat read, your pulls and pushes are shown to the owner under that session, until you reply
|
|
60
|
+
without --wip or 2 hours pass without any chat command.
|
|
61
|
+
--session <会话> 记到指定的会话 / record under this session
|
|
62
|
+
--session none 这次不记进任何会话 / record under no session`;
|
|
51
63
|
|
|
52
64
|
export const TASK_WORKSPACE_HELP = `anytowork task workspace — 这一单的工作空间 / the workspace of one task
|
|
53
65
|
只有这单当前的接单方能用,从选定你接单起到任务结束止。 / Only for the seller of this task, from being chosen until the task ends.
|
|
@@ -167,6 +179,29 @@ function makeCtx({ baseUrl, credOpts, fetchImpl, deps, err }) {
|
|
|
167
179
|
};
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
// ---------------------------------------------------------------- which workbench session the files belong to
|
|
183
|
+
|
|
184
|
+
const SESSION_HEADER = 'X-Linger-Wb-Session';
|
|
185
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* v0.32 (contract §7): the files the owner's Agent pulls / pushes are shown in the owner's Agent map under the
|
|
189
|
+
* workbench session it is working on. Order: --session <id> > --session none (record nowhere) > the current session
|
|
190
|
+
* kept on this computer (set by `chat read`) > nothing. Returns { headers } or { error } for a malformed --session.
|
|
191
|
+
*/
|
|
192
|
+
export function sessionHeaders(flags, credOpts, nowMs = Date.now()) {
|
|
193
|
+
const v = flags.session;
|
|
194
|
+
if (v != null) {
|
|
195
|
+
if (String(v).toLowerCase() === 'none') return { headers: { [SESSION_HEADER]: 'none' } };
|
|
196
|
+
if (!UUID_RE.test(String(v))) {
|
|
197
|
+
return { error: '--session 要写会话编号(chat list / chat read 里的那串编号),不想记进任何会话写 --session none。' };
|
|
198
|
+
}
|
|
199
|
+
return { headers: { [SESSION_HEADER]: String(v) } };
|
|
200
|
+
}
|
|
201
|
+
const current = readCurrentSession(credOpts, nowMs);
|
|
202
|
+
return { headers: current ? { [SESSION_HEADER]: current } : null };
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
// ---------------------------------------------------------------- space ls / pull / push
|
|
171
206
|
|
|
172
207
|
async function runLs(c) {
|
|
@@ -295,6 +330,9 @@ async function runSpacePush(c) {
|
|
|
295
330
|
export async function runSpace(p) {
|
|
296
331
|
const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
|
|
297
332
|
const c = { positionals, flags, json: flags.json, log, err, ctx: makeCtx({ baseUrl, credOpts, fetchImpl, deps: p.deps, err }) };
|
|
333
|
+
const hint = sessionHeaders(flags, credOpts, p.deps && p.deps.nowMs ? p.deps.nowMs() : Date.now());
|
|
334
|
+
if (hint.error) return usage(c, hint.error);
|
|
335
|
+
c.ctx.headers = hint.headers;
|
|
298
336
|
return guarded(c, () => {
|
|
299
337
|
if (command === 'space:ls') return runLs(c);
|
|
300
338
|
if (command === 'space:pull') return runSpacePull(c);
|
package/src/space-sync.js
CHANGED
|
@@ -80,12 +80,13 @@ function qs(params) {
|
|
|
80
80
|
return s ? `?${s}` : '';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ctx.headers: extra request headers (v0.32: `anytowork space` sends the workbench session, see space-commands.js)
|
|
83
84
|
export function apiGet(ctx, pathAndQuery) {
|
|
84
|
-
return withBackoff(ctx, () => authedGetWithRefresh(ctx.baseUrl, pathAndQuery, ctx.credOpts, { fetchImpl: ctx.fetchImpl }));
|
|
85
|
+
return withBackoff(ctx, () => authedGetWithRefresh(ctx.baseUrl, pathAndQuery, ctx.credOpts, { fetchImpl: ctx.fetchImpl, headers: ctx.headers }));
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export function apiPost(ctx, pathStr, body) {
|
|
88
|
-
return withBackoff(ctx, () => authedPostWithRefresh(ctx.baseUrl, pathStr, ctx.credOpts, body, null, { fetchImpl: ctx.fetchImpl }));
|
|
89
|
+
return withBackoff(ctx, () => authedPostWithRefresh(ctx.baseUrl, pathStr, ctx.credOpts, body, null, { fetchImpl: ctx.fetchImpl, headers: ctx.headers }));
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/** The fetch used for the object storage (signed addresses carry their own credentials, no login header). */
|