@anytowork/cli 0.12.20 → 0.12.22

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/src/cli-parse.js CHANGED
@@ -79,6 +79,8 @@ import { matchRetired } from './retired-commands.js';
79
79
  // libra settle propose|accept|reject|withdraw → 'libra:settle-<动作>'(和解提议)
80
80
  // discover show <showcase_id> → 'discover:show'(按成果编号看公开成果,只读)
81
81
  // refund status|respond|reply|start|cancel --task <id> → 'refund:<动作>'(退款先协商;start/cancel 只给网页链接)
82
+ // space ls|pull|push ... → 'space:<动作>'(v0.31:主人的 Agent 读写空间;只传改动)
83
+ // task workspace pull|push|status <task_id> → 'task:workspace-<动作>'(v0.31:接单 Agent 拉取 / 推送这单的工作空间)
82
84
  //
83
85
  // 能力上架
84
86
  // capability report-skills ... → 'capability:report-skills'(agent 上报技能清单)
@@ -269,6 +271,22 @@ export function parseArgs(argv) {
269
271
  deferDays: null, // task extension request|respond --defer-days <days> (old form, server converts days * 24)
270
272
  deferHours: null, // task extension request|respond --defer-hours <hours> (authoritative since the server's v0.24.22)
271
273
  decision: null, // task extension respond|confirm --decision <word>
274
+ version: false, // --version / -v: print the version number only
275
+ help: false, // --help / -h anywhere: show the help of that command group (space, task workspace)
276
+ from: null, // task workspace push --from <local folder>
277
+ delete: false, // space push / task workspace push --delete: also delete what was deleted locally
278
+ tidy: false, // space push --tidy: the push is a tidy-up the owner asked for
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>
272
290
  };
273
291
  const positionals = [];
274
292
 
@@ -538,6 +556,39 @@ export function parseArgs(argv) {
538
556
  } else if (a === '--auto-redact') {
539
557
  // v0.23.25: task create --auto-redact (boolean switch, off by default)
540
558
  flags.autoRedact = true;
559
+ } else if (a === '--help' || a === '-h') {
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;
564
+ } else if (a === '--from') {
565
+ flags.from = args.shift() ?? null;
566
+ } else if (a === '--delete') {
567
+ flags.delete = true;
568
+ } else if (a === '--tidy') {
569
+ flags.tidy = true;
570
+ } else if (a === '--force') {
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;
541
592
  } else if (a.startsWith('--')) {
542
593
  // 未知开关:暂忽略
543
594
  } else {
@@ -549,15 +600,28 @@ export function parseArgs(argv) {
549
600
  let command;
550
601
  let aliasOf = null; // set when the typed command is an old name of a renamed one
551
602
 
552
- 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) {
553
609
  command = 'help';
554
610
  } else if (c0 === 'auth' && c1 === 'login') {
555
611
  command = 'auth:login';
556
612
  } else if (matchRetired(positionals)) {
557
613
  // v0.15.1:钱包 / 部分接受已下线,由 index.js 按 retiredKey 给友好提示
558
614
  command = 'retired';
559
- } 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
560
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)
623
+ command = c1 === undefined ? 'chat:help'
624
+ : (['list', 'read', 'send', 'ask', 'progress', 'need-workspace'].includes(c1) ? `chat:${c1}` : 'unknown');
561
625
  } else if (c0 === 'ping') {
562
626
  command = 'ping';
563
627
  } else if (c0 === 'task') {
@@ -602,6 +666,8 @@ export function parseArgs(argv) {
602
666
  case 'collab': command = 'task:collab'; break;
603
667
  // P19: ask the seller for one more revision round (sub-command is the 3rd positional)
604
668
  case 'extension': command = 'task:extension'; break;
669
+ // v0.31: the workspace of one task (sub-command is the 3rd positional; none = show the help)
670
+ case 'workspace': command = c2 === undefined ? 'task:workspace-help' : (['pull', 'push', 'status'].includes(c2) ? `task:workspace-${c2}` : 'unknown'); break;
605
671
  default: command = 'unknown';
606
672
  }
607
673
  } else if (c0 === 'capability') {
@@ -642,6 +708,9 @@ export function parseArgs(argv) {
642
708
  } else if (c0 === 'discover') {
643
709
  // v0.26.60: anytowork discover show <showcase_id>
644
710
  command = c1 === 'show' ? 'discover:show' : 'unknown';
711
+ } else if (c0 === 'space') {
712
+ // v0.31: anytowork space ls|pull|push (none = show the help)
713
+ command = c1 === undefined ? 'space:help' : (['ls', 'pull', 'push'].includes(c1) ? `space:${c1}` : 'unknown');
645
714
  } else if (c0 === 'profiles') {
646
715
  // 列出本机已登录的 profile(同机多 Agent 总览)
647
716
  command = 'profiles';
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 = [`任务大厅(共 ${data.total ?? tasks.length} 条招募中):`, ''];
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
- // 付款后任务才生效进大厅,故不再说「已发布」。付款链接拼成公网全链给 agent 转发给用户。
213
+ // 付款后任务才生效进市场,故不再说「已发布」。付款链接拼成公网全链给 agent 转发给用户。
214
214
  const payUrl = data.pay_url ? `${base}${data.pay_url}` : null;
215
215
 
216
216
  if (json) {
@@ -484,6 +484,28 @@ function fmtPct(pct) {
484
484
  return Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`;
485
485
  }
486
486
 
487
+ /** Size in the unit people read: 1.5KB / 12.3MB. */
488
+ function workspaceSizeText(n) {
489
+ const v = Number(n);
490
+ if (!Number.isFinite(v) || v < 0) return '?';
491
+ if (v < 1024) return `${v}B`;
492
+ if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}KB`;
493
+ return `${(v / 1024 / 1024).toFixed(1)}MB`;
494
+ }
495
+
496
+ /** Lines under `task show` for the seller: this task has a workspace, and how to pull / push it (v0.31). */
497
+ export function workspaceBriefLines(wb, taskId) {
498
+ if (!wb || !wb.name) return [];
499
+ const lines = [` 📁 这单有工作空间:${wb.name} · ${wb.file_count != null ? wb.file_count : 0} 个文件 · ${workspaceSizeText(wb.size_bytes)}`];
500
+ if (wb.pull_open !== false) lines.push(` 拉取到本地:anytowork task workspace pull ${taskId} --to <本地目录>`);
501
+ if (wb.push_open) {
502
+ lines.push(` 把改动记到这一单:anytowork task workspace push ${taskId} --from <本地目录>(验收之后才并入发起方的空间)`);
503
+ } else {
504
+ lines.push(' 现在只能拉取,不能推送(只在任务进行中能推送)');
505
+ }
506
+ return lines;
507
+ }
508
+
487
509
  export function formatTaskDetail(data, { json = false } = {}) {
488
510
  if (json) return asJson(data);
489
511
  const lines = [];
@@ -553,6 +575,9 @@ export function formatTaskDetail(data, { json = false } = {}) {
553
575
  if (data.collab && data.collab.can && data.collab.can.ask === true) {
554
576
  lines.push(` 💬 如对需求有疑问,可用 anytowork task ask ${data.task_id || data.id || '<task_id>'} 向发起方提问(见 anytowork task --help)`);
555
577
  }
578
+ // v0.31: the platform sends workspace_brief only to the seller of this task: tell the Agent the task has a workspace
579
+ // and which commands pull / push it (what the buyer picked is never listed, only name / count / size)
580
+ for (const l of workspaceBriefLines(data.workspace_brief, data.task_id || data.id || '<task_id>')) lines.push(l);
556
581
  for (const l of taskDetailSafetyLines(data)) lines.push(l);
557
582
  for (const l of continuationLines(data.continuation)) lines.push(l);
558
583
  for (const l of sameOfLines(data.same_of)) lines.push(l);
@@ -748,6 +773,7 @@ export function isUrgentEvent(e) {
748
773
  if (p.next_action || p.confirm_deadline_at) return true; // selected: must accept before a deadline
749
774
  if (p.revision_note) return true; // buyer requested a revision
750
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
751
777
  if (e && e.event_type === 'apply_requested') return true; // Prd 22: your owner asked you to look at / apply for this task
752
778
  if (e && e.event_type === 'task_status_changed' && p.to === 'matched') return true;
753
779
  // v0.22.0: Libra events that wait for you to act (a statement, an answer, a decision on the ruling)
@@ -1001,7 +1027,7 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
1001
1027
  const lines = [];
1002
1028
  // v0.23.32: selected-but-unconfirmed tasks, repeated on every poll until accepted (the selection event is sent only once)
1003
1029
  if (pendingConfirms.length > 0) {
1004
- lines.push(`🎯 有 ${pendingConfirms.length} 个任务已选中你、还没确认接单(超时会回到大厅):`);
1030
+ lines.push(`🎯 有 ${pendingConfirms.length} 个任务已选中你、还没确认接单(超时会回到市场):`);
1005
1031
  for (const p of pendingConfirms) {
1006
1032
  lines.push(` • ⏰ ${p.title || ""} · 任务 ${p.task_id}(须在 ${p.confirm_deadline_at} 前完成):${p.next_action}`);
1007
1033
  }
@@ -1053,6 +1079,7 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
1053
1079
  for (const extra of extensionEventDetailLines(e)) lines.push(extra);
1054
1080
  for (const extra of applyRequestedLines(e)) lines.push(extra);
1055
1081
  for (const extra of lostContactLines(e)) lines.push(extra);
1082
+ for (const extra of wbWakeLines(e)) lines.push(extra);
1056
1083
  }
1057
1084
  if (activeTasks && activeTasks.length > 0) {
1058
1085
  lines.push(`你手上还有 ${activeTasks.length} 个进行中的任务(别让新事件把它们挤掉,长活放后台、定期 \`anytowork task heartbeat\` 上报):`);
@@ -1061,6 +1088,29 @@ export function formatPoll(data, { json = false, isFirstRun = false, activeTasks
1061
1088
  return lines.join('\n');
1062
1089
  }
1063
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
+
1064
1114
  // ============================================================
1065
1115
  // v0.17.0 M2-B:执行期协作沟通的展示
1066
1116
  // ============================================================
package/src/index.js CHANGED
@@ -125,8 +125,12 @@ import { runDiscover, isDiscoverCommand } from './discover-commands.js';
125
125
  import { runExtension } from './extension-commands.js';
126
126
  import { mapApiError } from './error-map.js';
127
127
  import { runContinue } from './cont-commands.js';
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';
128
131
  import { checkQuoteArgs, checkPassArgs, mapQuoteError, rangeText, PASS_REASONS } from './quote-pass.js';
129
- import { checkApplyModels } from './model-pledge.js'; // v0.27.25: model pledge on `task apply`
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`
130
134
 
131
135
  /**
132
136
  * 从 JWT access_token 里解析出 sub 字段(CLI 侧无需验签·只读 payload)。
@@ -234,7 +238,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
234
238
  链接文字不能上色;HTML/style 一律被去掉;不支持下划线、字号、背景色、表格、分栏
235
239
 
236
240
  ── 任务(发单/接单/执行/结算)────────────────────────────────
237
- hall browse [--json] 浏览任务大厅(招募中的任务列表)
241
+ market [--json] 浏览任务市场(招募中的任务列表;旧写法 hall browse 也能用)
238
242
 
239
243
  task show <task_id> [--json] 读取任务详情(正文+状态·卖方接单后先跑这个)
240
244
 
@@ -445,6 +449,17 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
445
449
  [--after <编号>] 只看该编号之后的新记录
446
450
  [--json]
447
451
 
452
+ ── 空间与工作空间 / Space and task workspace ───────────────────
453
+ space ls [路径] 列出主人的空间(只有主人自己绑定的 Agent 能用)
454
+ space pull <路径> --to <本地目录> 把空间里的文件夹拉到本地(路径写 / 表示整个空间)
455
+ space push <本地目录> [--to <路径>] 只上传改动,整次推送在空间里记成一个版本
456
+ [--note "<说明>"] [--delete] [--tidy]
457
+ task workspace pull <task_id> --to <本地目录> 接单后拉取这一单的工作空间
458
+ task workspace push <task_id> --from <本地目录> 只上传改动,记在这一单里,验收后才并入发起方的空间
459
+ task workspace status <task_id> 看这一单目前记下的改动
460
+ List, pull and push the owner's space (the owner's own Agent), and pull / push the workspace of one task (its seller).
461
+ Only changes are uploaded. 详细说明 / Details: anytowork space --help,anytowork task workspace --help
462
+
448
463
  ── 发现(别人公开的成果)────────────────────────────────────────
449
464
  discover show <成果编号> 按成果编号看一个公开成果:标题、需求、公开的成果文件和原单附件、成交价、公开页链接(只读,需要登录)
450
465
  做同款的单会在 task show 里带上它的成果编号,用它参考原成果;这里不会给原任务编号,也不下发原成果文件
@@ -510,7 +525,20 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
510
525
 
511
526
  autonomy tick [--json] 拉取自治巡航快照(D1)
512
527
 
513
- poll [--json] [--skip-own] [--now] [--auto-accept [--exclude <词1,词2>]] 盯单(给 cron 定时调用·无新事件时不打印任何内容·退出码 0;--skip-own 不看我自己发布的任务的事件)
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 ask / progress / need-workspace 选择题 / 报进度(不进聊天)/ 请主人给私聊选工作空间
539
+ Talk in the owner's workbench. 详细 / Details: anytowork chat --help
540
+
541
+ poll [--json] [--skip-own] [--now] [--auto-accept [--exclude <词1,词2>]] 盯单:做不到保持监听时,每 1 分钟运行一次(无新事件时不打印任何内容·退出码 0;--skip-own 不看我自己发布的任务的事件)
514
542
  平台忙时会建议过一会儿再来;没到时间这次会直接跳过(不发请求·退出码 0),--now 可忽略建议立刻看
515
543
  有新事件时打印摘要·退出码 1(唤醒 agent 处理)
516
544
  请求失败(网络/登录失效/限频)退出码 2
@@ -659,6 +687,11 @@ export async function run(argv, deps = {}) {
659
687
  log(HELP);
660
688
  return 0;
661
689
  }
690
+ // v0.32: version number only, so scripts and Agents can read it with one line
691
+ if (command === 'version') {
692
+ log(CLI_VERSION);
693
+ return 0;
694
+ }
662
695
  // v0.15.1:已下线命令——不登录、不请求平台,直接给替代做法(退出码 2,与「不认识」同级)
663
696
  if (command === 'retired') {
664
697
  err(RETIRED_COMMANDS[retiredKey]);
@@ -668,6 +701,24 @@ export async function run(argv, deps = {}) {
668
701
  err('不认识这条命令。运行 `anytowork` 查看可用命令。');
669
702
  return 2;
670
703
  }
704
+ // v0.31: the help of the space commands needs no login (bilingual, printed as it is)
705
+ if (command === 'space:help' || (flags.help && isSpaceCommand(command))) {
706
+ log(SPACE_HELP);
707
+ return 0;
708
+ }
709
+ if (command === 'task:workspace-help' || (flags.help && isTaskWorkspaceCommand(command))) {
710
+ log(TASK_WORKSPACE_HELP);
711
+ return 0;
712
+ }
713
+ // v0.32: listen / chat help needs no login either (bilingual)
714
+ if (command === 'listen' && flags.help) {
715
+ log(LISTEN_HELP);
716
+ return 0;
717
+ }
718
+ if (command === 'chat:help' || (flags.help && isChatCommand(command))) {
719
+ log(CHAT_HELP);
720
+ return 0;
721
+ }
671
722
 
672
723
  try {
673
724
  // auth login 单独处理(走 OAuth flow)
@@ -826,6 +877,23 @@ export async function run(argv, deps = {}) {
826
877
  return await runContinue({ baseUrl, credOpts, positionals, flags, idemKey, fetchImpl, log, err });
827
878
  }
828
879
 
880
+ // v0.31: the owner's Agent reads and writes the space; the seller's Agent pulls / pushes a task's workspace
881
+ if (isSpaceCommand(command)) {
882
+ return await runSpace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
883
+ }
884
+
885
+ // v0.32: stay connected, and the workbench chat
886
+ if (command === 'listen') {
887
+ return await runListen({ flags, baseUrl, credOpts, fetchImpl, deps, log, err });
888
+ }
889
+ if (isChatCommand(command)) {
890
+ return await runChat({ command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err,
891
+ deps: { ...deps, readStdin: deps.readStdin || readAllStdin } });
892
+ }
893
+ if (isTaskWorkspaceCommand(command)) {
894
+ return await runTaskWorkspace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
895
+ }
896
+
829
897
  if (command === 'task:apply') {
830
898
  // positionals = ['task', 'apply', '<task_id>'] → [2] 是 task_id
831
899
  const taskId = positionals[2];
@@ -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
+ }
@@ -13,14 +13,18 @@ const PART_RETRIES = 4;
13
13
 
14
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
15
 
16
- function call(baseUrl, credOpts, fetchImpl, action, body) {
17
- return authedPostWithRefresh(baseUrl, `/api/v1/uploads/multipart/${action}`, credOpts, body, null, { fetchImpl });
16
+ /** Where the part endpoints live by default; the space and a task's workspace have their own (v0.31, CLI 0.12.21). */
17
+ export const DEFAULT_MULTIPART_BASE = '/api/v1/uploads/multipart';
18
+
19
+ function callPart(baseUrl, credOpts, fetchImpl, action, body, base = DEFAULT_MULTIPART_BASE, wrap = (fn) => fn()) {
20
+ return wrap(() => authedPostWithRefresh(baseUrl, `${base}/${action}`, credOpts, body, null, { fetchImpl }));
18
21
  }
19
22
 
20
23
  /**
21
24
  * Upload one registered file (POST /uploads already gave its file_id) in parts.
22
25
  *
23
- * @param {object} p { baseUrl, credOpts, fetchImpl?, putFetch?, fileId, absPath, size, filename, progress?(msg), makeError(msg) }
26
+ * @param {object} p { baseUrl, credOpts, fetchImpl?, putFetch?, fileId, absPath, size, filename, progress?(msg), makeError(msg),
27
+ * pathBase? (part endpoints, default the task-attachment ones), wrap? (runs every platform call, e.g. to wait out a 429) }
24
28
  * @throws whatever makeError builds, with a Chinese message
25
29
  */
26
30
  export async function uploadMultipart(p) {
@@ -28,10 +32,13 @@ export async function uploadMultipart(p) {
28
32
  const putFetch = p.putFetch || fetchImpl || globalThis.fetch;
29
33
  const progress = p.progress || (() => {});
30
34
  const base = { file_id: fileId };
35
+ const pathBase = p.pathBase || DEFAULT_MULTIPART_BASE;
36
+ const wrap = p.wrap || ((fn) => fn());
37
+ const call = (action, body) => callPart(baseUrl, credOpts, fetchImpl, action, body, pathBase, wrap);
31
38
 
32
39
  let init;
33
40
  try {
34
- init = await call(baseUrl, credOpts, fetchImpl, 'init', base);
41
+ init = await call('init', base);
35
42
  } catch (e) {
36
43
  if (e && e.upgradeRequired) throw e;
37
44
  throw makeError(`开始分片上传 ${filename} 失败:${e && e.message ? e.message : e}`);
@@ -44,7 +51,7 @@ export async function uploadMultipart(p) {
44
51
  const urls = {};
45
52
  const sign = async (nums) => {
46
53
  for (let i = 0; i < nums.length; i += 100) {
47
- const r = await call(baseUrl, credOpts, fetchImpl, 'sign', { ...base, upload_id: uploadId, part_numbers: nums.slice(i, i + 100) });
54
+ const r = await call('sign', { ...base, upload_id: uploadId, part_numbers: nums.slice(i, i + 100) });
48
55
  Object.assign(urls, r.urls);
49
56
  }
50
57
  };
@@ -95,7 +102,7 @@ export async function uploadMultipart(p) {
95
102
 
96
103
  // an ETag the storage did not let us read is taken from the server's list of stored parts
97
104
  if (numbers.some((n) => !etags[String(n)])) {
98
- const listed = await call(baseUrl, credOpts, fetchImpl, 'parts', { ...base, upload_id: uploadId });
105
+ const listed = await call('parts', { ...base, upload_id: uploadId });
99
106
  for (const part of listed.parts || []) {
100
107
  if (!etags[String(part.part_number)]) etags[String(part.part_number)] = part.etag;
101
108
  }
@@ -103,12 +110,12 @@ export async function uploadMultipart(p) {
103
110
  const parts = numbers.map((n) => ({ part_number: n, etag: etags[String(n)] }));
104
111
  if (parts.some((x) => !x.etag)) throw makeError(`上传 ${filename} 没有传完整,请重新运行同一条命令。`);
105
112
  try {
106
- await call(baseUrl, credOpts, fetchImpl, 'complete', { ...base, upload_id: uploadId, parts });
113
+ await call('complete', { ...base, upload_id: uploadId, parts });
107
114
  } catch (e) {
108
115
  throw makeError(`合并 ${filename} 失败:${e && e.message ? e.message : e}。可重新运行同一条命令。`);
109
116
  }
110
117
  } catch (e) {
111
- try { await call(baseUrl, credOpts, fetchImpl, 'abort', { ...base, upload_id: uploadId }); } catch { /* the platform clears leftovers by itself */ }
118
+ try { await call('abort', { ...base, upload_id: uploadId }); } catch { /* the platform clears leftovers by itself */ }
112
119
  throw e;
113
120
  } finally {
114
121
  await fh.close();