toc-ocra 0.2.8 → 0.2.10

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 CHANGED
@@ -73,6 +73,20 @@ node dist/src/index.js run
73
73
  - **服务端对账兜底**:心跳会上报本机正在运行的任务,服务端据此释放本机已经不存在的运行;Agent 崩溃重启时,会清理上次遗留的 CLI 进程。
74
74
  - **加一个 CLI**:在 `src/providers/cli/` 新增一个文件,实现 `HeadlessProvider`(启动参数 + 解析每一行输出),再在 `src/providers/registry.ts` 加一行。
75
75
 
76
+ ### 看板附件
77
+
78
+ 任务链附件在 CLI 启动前由本机代理下载到本次实际工作目录的 `.echo/attachments/message-{message_id}/`,完成大小与 SHA-256 校验后,提示词中的附件引用会替换为本机绝对路径。下载目录必须属于已允许的 CLI 工作目录,不能通过符号链接跳出;未配置设备默认目录或工位工作目录时会拒绝附件任务。每条消息最多 16 个附件,准备过程可取消、可复用已校验文件,最长等待 15 分钟。
79
+
80
+ 窗口工位使用同机代理的 `run.files.prepare` 先下载文件,然后由服务端转发路径;同机设备代理必须在线。更新后的代理通过心跳声明 `task_attachments.version=1`,服务端会拒绝把附件任务发给不支持此协议的旧代理。请更新并重启设备代理与对应后端后使用。
81
+
82
+ 下载文件暂时保留,可手工清理;`.echo` 不进入设备目录扫描,并带有仅作用于该内部目录的 Git 忽略规则。视频也可下载为文件,实际解析取决于 CLI 的能力。
83
+
84
+ `toc-ocra code` 会同时显示正在运行的后台服务版本与当前安装版本。显示连接码只说明可连接本机插件服务;更新 npm 安装后,旧后台进程不会自动加载新代码。请在任务结束后执行 `toc-ocra service:install` 重启,或使用 `toc-ocra update` 完成升级与重启。重启会停止本机正在运行的任务。
85
+
86
+ `toc-ocra update` 跟随当前命令实际所在的 npm 全局安装目录,避免 nvm 与 Homebrew 的 npm 前缀不同导致更新了另一份安装。源码目录运行时请更新源码、构建并重新安装服务。
87
+
88
+ CLI 聊天的流式文字保留空格、缩进和换行;内容清理使用独立于终端日志行的处理,避免把仅含空白的片段误变成空数据或破坏 Markdown 排版。
89
+
76
90
  详见 [docs/architecture.md](docs/architecture.md)。
77
91
 
78
92
  ## 开发
@@ -7,7 +7,7 @@ var __export = (target, all) => {
7
7
 
8
8
  // src/plugin-service.ts
9
9
  import { existsSync as existsSync6 } from "node:fs";
10
- import { delimiter as delimiter5, join as join9 } from "node:path";
10
+ import { delimiter as delimiter5, join as join10 } from "node:path";
11
11
 
12
12
  // src/config.ts
13
13
  import { randomUUID } from "node:crypto";
@@ -15731,8 +15731,8 @@ var CliDetectionService = class {
15731
15731
  var cliDetection = new CliDetectionService();
15732
15732
 
15733
15733
  // src/runtime/agent-runtime.ts
15734
- import { randomUUID as randomUUID2 } from "node:crypto";
15735
- import { join as join8 } from "node:path";
15734
+ import { randomUUID as randomUUID3 } from "node:crypto";
15735
+ import { join as join9 } from "node:path";
15736
15736
 
15737
15737
  // src/exec/script-runner.ts
15738
15738
  import { spawn as spawn3 } from "node:child_process";
@@ -16717,17 +16717,21 @@ function commandOf(pid) {
16717
16717
 
16718
16718
  // src/runs/run-supervisor.ts
16719
16719
  import { spawn as spawn7 } from "node:child_process";
16720
- import { mkdir as mkdir4, realpath, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
16721
- import { basename as basename2, isAbsolute as isAbsolute2, join as join6, relative, resolve as resolve3 } from "node:path";
16720
+ import { mkdir as mkdir5, realpath as realpath2, stat as stat2, writeFile as writeFile5 } from "node:fs/promises";
16721
+ import { basename as basename3, isAbsolute as isAbsolute3, join as join7, relative as relative2, resolve as resolve3 } from "node:path";
16722
16722
  import { createInterface as createInterface2 } from "node:readline";
16723
16723
 
16724
16724
  // src/session/terminal-line-sanitizer.ts
16725
16725
  var TERMINAL_ESCAPE = /\u001b(?:\][^\u0007]*?(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
16726
16726
  var CONTROL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
16727
+ var CHAT_CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
16727
16728
  var SENSITIVE_VALUE = /\b(?:sk|rk|pk|ghp|github_pat)_[A-Za-z0-9._-]+|\bBearer\s+\S+|\b(?:api[_-]?key|token|authorization|x-mcp-token)\s*[:=]\s*\S[^\r\n]*/gi;
16728
16729
  function sanitizeTerminalLine(line) {
16729
16730
  return line.replace(TERMINAL_ESCAPE, "").replace(CONTROL_CHARACTER, "").replace(SENSITIVE_VALUE, "[redacted]").replace(/\s+$/, "");
16730
16731
  }
16732
+ function sanitizeChatText(text) {
16733
+ return text.replace(TERMINAL_ESCAPE, "").replace(CHAT_CONTROL_CHARACTER, "").replace(SENSITIVE_VALUE, "[redacted]");
16734
+ }
16731
16735
 
16732
16736
  // src/session/terminal-tail-buffer.ts
16733
16737
  var MAX_TAIL_LINES = 2e3;
@@ -16837,6 +16841,88 @@ function clipToCharacterBudget(rows, budget) {
16837
16841
  return { lines: rows, limited: false };
16838
16842
  }
16839
16843
 
16844
+ // src/runs/task-attachments.ts
16845
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
16846
+ import { constants as constants2 } from "node:fs";
16847
+ import { lstat, mkdir as mkdir4, open, realpath, rename as rename4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
16848
+ import { basename as basename2, isAbsolute as isAbsolute2, join as join6, relative } from "node:path";
16849
+ function within(root, path) {
16850
+ const segment = relative(root, path);
16851
+ return segment === "" || !segment.startsWith("..") && !isAbsolute2(segment);
16852
+ }
16853
+ async function attachmentDirectory(workdir, messageId) {
16854
+ let directory = workdir;
16855
+ for (const segment of [".echo", "attachments", `message-${encodeURIComponent(messageId).slice(0, 180)}`]) {
16856
+ directory = join6(directory, segment);
16857
+ await mkdir4(directory, { mode: 448 }).catch((error51) => {
16858
+ if (error51.code !== "EEXIST") throw error51;
16859
+ });
16860
+ const metadata = await lstat(directory);
16861
+ if (metadata.isSymbolicLink() || !metadata.isDirectory() || !within(workdir, await realpath(directory))) {
16862
+ throw new Error("\u9644\u4EF6\u76EE\u5F55\u5FC5\u987B\u4F4D\u4E8E CLI \u5DE5\u4F5C\u76EE\u5F55\u5185\uFF0C\u4E14\u4E0D\u80FD\u662F\u7B26\u53F7\u94FE\u63A5");
16863
+ }
16864
+ if (segment === ".echo") await writeFile4(join6(directory, ".gitignore"), "*\n", { flag: "wx", mode: 384 }).catch((error51) => {
16865
+ if (error51.code !== "EEXIST") throw error51;
16866
+ });
16867
+ }
16868
+ return directory;
16869
+ }
16870
+ async function matches(path, size, sha256, signal2) {
16871
+ const metadata = await lstat(path).catch(() => void 0);
16872
+ if (!metadata?.isFile() || metadata.isSymbolicLink() || metadata.size !== size) return false;
16873
+ const file2 = await open(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
16874
+ try {
16875
+ const hash2 = createHash2("sha256");
16876
+ let read = 0;
16877
+ for await (const chunk of file2.createReadStream({ autoClose: false })) {
16878
+ signal2.throwIfAborted();
16879
+ read += chunk.length;
16880
+ if (read > size) return false;
16881
+ hash2.update(chunk);
16882
+ }
16883
+ return hash2.digest("hex") === sha256;
16884
+ } finally {
16885
+ await file2.close();
16886
+ }
16887
+ }
16888
+ async function prepareTaskAttachments(payload, workdir, download, signal2) {
16889
+ const files = payload.task_attachments ?? [];
16890
+ if (!files.length) return payload.prompt;
16891
+ const directory = await attachmentDirectory(workdir, payload.message_id || payload.run_id);
16892
+ let prompt = payload.prompt;
16893
+ const paths = [];
16894
+ for (const file2 of files) {
16895
+ signal2.throwIfAborted();
16896
+ const token = `{{echo-file:${file2.file_id}}}`;
16897
+ if (file2.prompt_token !== token) throw new Error("\u9644\u4EF6\u8DEF\u5F84\u5360\u4F4D\u7B26\u65E0\u6548\uFF0C\u8BF7\u66F4\u65B0\u670D\u52A1\u5668");
16898
+ const characters = Array.from(basename2(file2.name.replace(/\\/g, "/")).replace(/[^\p{L}\p{N}._-]/gu, "_").replace(/\.+$/u, ""));
16899
+ while (Buffer.byteLength(characters.join("")) > 160) characters.shift();
16900
+ const name = characters.join("") || "attachment";
16901
+ const path = join6(directory, `${file2.file_id}-${name}`);
16902
+ if (!await matches(path, file2.size, file2.sha256, signal2)) {
16903
+ const temporary = join6(directory, `.download-${file2.file_id}-${randomUUID2()}.part`);
16904
+ try {
16905
+ await download(payload.run_id, file2.file_id, temporary, file2.size, file2.sha256, signal2);
16906
+ signal2.throwIfAborted();
16907
+ if (!within(workdir, await realpath(directory))) throw new Error("\u9644\u4EF6\u76EE\u5F55\u5DF2\u6539\u53D8\uFF0C\u8BF7\u91CD\u8BD5");
16908
+ await rename4(temporary, path);
16909
+ } finally {
16910
+ await rm2(temporary, { force: true });
16911
+ }
16912
+ }
16913
+ if (!within(workdir, await realpath(path))) throw new Error("\u9644\u4EF6\u8DEF\u5F84\u4E0D\u5728 CLI \u5DE5\u4F5C\u76EE\u5F55\u5185");
16914
+ prompt = prompt.split(token).join(path);
16915
+ paths.push(`- ${JSON.stringify(path)}\uFF08${file2.mime}\uFF0C${file2.size} \u5B57\u8282\uFF09`);
16916
+ }
16917
+ if (prompt.includes("{{echo-file:")) throw new Error("\u9644\u4EF6\u8DEF\u5F84\u672A\u5B8C\u6574\u89E3\u6790");
16918
+ prompt += `
16919
+
16920
+ \u9644\u4EF6\u5DF2\u7531\u8BBE\u5907\u4EE3\u7406\u4E0B\u8F7D\u5230\u5F53\u524D\u5DE5\u4F5C\u76EE\u5F55\u3002\u8BF7\u76F4\u63A5\u8BFB\u53D6\u4EE5\u4E0B\u672C\u673A\u6587\u4EF6\uFF0C\u65E0\u9700\u4E0B\u8F7D\uFF1A
16921
+ ${paths.join("\n")}`;
16922
+ if (Buffer.byteLength(prompt) > 512 * 1024) throw new Error("\u5305\u542B\u9644\u4EF6\u8DEF\u5F84\u7684\u6D88\u606F\u8FC7\u957F");
16923
+ return prompt;
16924
+ }
16925
+
16840
16926
  // src/runs/run-supervisor.ts
16841
16927
  var COMPLETION_EXIT_GRACE_MS = 15e3;
16842
16928
  var RECENT_RUN_TTL_MS = 10 * 6e4;
@@ -16858,12 +16944,13 @@ var RunSupervisor = class {
16858
16944
  /** Run ids whose finish report has not been acknowledged yet. */
16859
16945
  unreported = /* @__PURE__ */ new Set();
16860
16946
  chatRunIds = /* @__PURE__ */ new Set();
16947
+ preparing = /* @__PURE__ */ new Map();
16861
16948
  get activeCount() {
16862
- return this.active.size;
16949
+ return this.active.size + this.preparing.size;
16863
16950
  }
16864
16951
  /** Runs to list in the heartbeat: still running, or finished but not yet reported. */
16865
16952
  activeRunIds() {
16866
- return [.../* @__PURE__ */ new Set([...this.active.keys(), ...this.unreported])];
16953
+ return [.../* @__PURE__ */ new Set([...this.active.keys(), ...this.preparing.keys(), ...this.unreported])];
16867
16954
  }
16868
16955
  activeChatRunIds() {
16869
16956
  return this.activeRunIds().filter((id) => this.chatRunIds.has(id));
@@ -16885,6 +16972,16 @@ var RunSupervisor = class {
16885
16972
  * directory, unknown CLI) are reported as a failed run, never thrown.
16886
16973
  */
16887
16974
  async start(payload) {
16975
+ if (this.preparing.has(payload.run_id)) return;
16976
+ const controller = new AbortController();
16977
+ this.preparing.set(payload.run_id, { workstationId: payload.workstation_id, controller });
16978
+ try {
16979
+ await this.startPrepared(payload, controller.signal);
16980
+ } finally {
16981
+ this.preparing.delete(payload.run_id);
16982
+ }
16983
+ }
16984
+ async startPrepared(payload, signal2) {
16888
16985
  if (this.active.has(payload.run_id) || this.recent.has(payload.run_id)) {
16889
16986
  this.logger.info("run-duplicate-ignored", { runId: payload.run_id });
16890
16987
  return;
@@ -16895,20 +16992,32 @@ var RunSupervisor = class {
16895
16992
  this.remember({ runId: payload.run_id, workstationId: payload.workstation_id, tail: new TerminalTailBuffer(), finishedAt: Date.now(), status: "failed", chat: !!payload.chat_session_id });
16896
16993
  await this.report(payload.run_id, { status: "failed", error: error51, duration_ms: 0 });
16897
16994
  };
16898
- if ([...this.active.values()].some((run2) => run2.workstationId === payload.workstation_id)) {
16995
+ if ([...this.active.values()].some((run2) => run2.workstationId === payload.workstation_id) || [...this.preparing].some(([id, run2]) => id !== payload.run_id && run2.workstationId === payload.workstation_id)) {
16899
16996
  return refuse("\u672C\u673A\u8BE5\u5DE5\u4F4D\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C");
16900
16997
  }
16901
- if (this.active.size >= this.config.maxConcurrentRuns) {
16998
+ if (this.active.size + this.preparing.size > this.config.maxConcurrentRuns) {
16902
16999
  return refuse(`\u672C\u673A\u540C\u65F6\u8FD0\u884C\u7684\u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650\uFF08${this.config.maxConcurrentRuns}\uFF09`);
16903
17000
  }
16904
17001
  let provider;
16905
17002
  let workdir;
16906
17003
  try {
16907
17004
  provider = resolveHeadlessProvider(payload.agent_type);
17005
+ if (payload.task_attachments?.length && !payload.workdir) throw new Error("\u9644\u4EF6\u9700\u8981\u914D\u7F6E\u8BBE\u5907\u9ED8\u8BA4\u76EE\u5F55\u6216\u5DE5\u4F4D\u5DE5\u4F5C\u76EE\u5F55");
16908
17006
  workdir = payload.chat_session_id && !payload.workdir ? await this.defaultChatWorkdir() : await this.validateWorkdir(payload.workdir || this.config.allowedRoots[0]);
16909
17007
  } catch (error51) {
16910
17008
  return refuse(errorMessage(error51));
16911
17009
  }
17010
+ try {
17011
+ if (payload.task_attachments?.length) payload = { ...payload, prompt: await this.prepareTaskFiles(payload, signal2, workdir) };
17012
+ signal2.throwIfAborted();
17013
+ } catch (error51) {
17014
+ if (signal2.aborted) {
17015
+ this.remember({ runId: payload.run_id, workstationId: payload.workstation_id, tail: new TerminalTailBuffer(), finishedAt: Date.now(), status: "cancelled", chat: !!payload.chat_session_id });
17016
+ await this.report(payload.run_id, { status: "cancelled", error: "\u9644\u4EF6\u51C6\u5907\u5DF2\u53D6\u6D88", duration_ms: 0 });
17017
+ return;
17018
+ }
17019
+ return refuse(`\u9644\u4EF6\u4E0B\u8F7D\u5931\u8D25\uFF1A${errorMessage(error51)}`);
17020
+ }
16912
17021
  const request = {
16913
17022
  prompt: payload.prompt,
16914
17023
  sessionId: payload.session_id,
@@ -16927,7 +17036,7 @@ var RunSupervisor = class {
16927
17036
  }
16928
17037
  if (!payload.prompt && !payload.attachments?.length) return refuse("\u6D88\u606F\u5185\u5BB9\u4E3A\u7A7A");
16929
17038
  if ([...this.active.values()].some((run2) => run2.workstationId === payload.workstation_id)) return refuse("\u672C\u673A\u8BE5\u5DE5\u4F4D\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C");
16930
- if (this.active.size >= this.config.maxConcurrentRuns) return refuse("\u672C\u673A\u540C\u65F6\u8FD0\u884C\u7684\u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650");
17039
+ if (this.active.size + this.preparing.size > this.config.maxConcurrentRuns) return refuse("\u672C\u673A\u540C\u65F6\u8FD0\u884C\u7684\u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650");
16931
17040
  const launch = provider.buildLaunch({
16932
17041
  prompt: payload.prompt,
16933
17042
  sessionId: payload.session_id,
@@ -16976,6 +17085,7 @@ var RunSupervisor = class {
16976
17085
  timers: []
16977
17086
  };
16978
17087
  this.active.set(run.runId, run);
17088
+ this.preparing.delete(run.runId);
16979
17089
  this.unreported.add(run.runId);
16980
17090
  if (run.pid > 1) this.orphans?.add({ runId: run.runId, pid: run.pid, executable: launch.executable, startedAt: run.startedAt });
16981
17091
  run.tail.append(`\u25CF ${provider.id} \u542F\u52A8\u4E8E ${workdir}${payload.session_id ? `\uFF08\u7EE7\u7EED\u4F1A\u8BDD ${payload.session_id}\uFF09` : ""}
@@ -16995,7 +17105,7 @@ var RunSupervisor = class {
16995
17105
  run.tail.append(`${clean}
16996
17106
  `);
16997
17107
  });
16998
- child.once("close", (code, signal2) => void this.onExit(run, code, signal2, spawnError));
17108
+ child.once("close", (code, signal3) => void this.onExit(run, code, signal3, spawnError));
16999
17109
  if (provider.startInput) {
17000
17110
  child.once("spawn", () => {
17001
17111
  try {
@@ -17130,16 +17240,16 @@ var RunSupervisor = class {
17130
17240
  async prepareChatAttachments(payload) {
17131
17241
  if (!payload.attachments?.length) return [];
17132
17242
  if (!this.reporter.chatMedia) throw new Error("\u9644\u4EF6\u8F6C\u53D1\u672A\u914D\u7F6E");
17133
- const directory = join6(this.config.dataDir, "chat-media", encodeURIComponent(payload.chat_session_id ?? payload.run_id));
17134
- await mkdir4(directory, { recursive: true, mode: 448 });
17243
+ const directory = join7(this.config.dataDir, "chat-media", encodeURIComponent(payload.chat_session_id ?? payload.run_id));
17244
+ await mkdir5(directory, { recursive: true, mode: 448 });
17135
17245
  const files = [];
17136
17246
  for (const attachment of payload.attachments) {
17137
- const name = basename2(attachment.name).replace(/[^\p{L}\p{N}._-]/gu, "_").slice(-120) || "attachment";
17138
- const path = join6(directory, `${encodeURIComponent(attachment.media_id)}-${name}`);
17247
+ const name = basename3(attachment.name).replace(/[^\p{L}\p{N}._-]/gu, "_").slice(-120) || "attachment";
17248
+ const path = join7(directory, `${encodeURIComponent(attachment.media_id)}-${name}`);
17139
17249
  const existing = await stat2(path).catch(() => void 0);
17140
17250
  if (!existing?.isFile()) {
17141
17251
  const data = await this.reporter.chatMedia(payload.run_id, attachment.media_id);
17142
- await writeFile4(path, data, { mode: 384 });
17252
+ await writeFile5(path, data, { mode: 384 });
17143
17253
  }
17144
17254
  files.push({ path, name: attachment.name, kind: attachment.kind });
17145
17255
  }
@@ -17152,7 +17262,14 @@ var RunSupervisor = class {
17152
17262
  /** Stops a run: the whole process tree, then a `cancelled` report. */
17153
17263
  async cancel(runId) {
17154
17264
  const run = this.active.get(runId);
17155
- if (!run) return false;
17265
+ if (!run) {
17266
+ const preparing = this.preparing.get(runId);
17267
+ if (preparing) {
17268
+ preparing.controller.abort();
17269
+ return true;
17270
+ }
17271
+ return false;
17272
+ }
17156
17273
  run.cancelRequested = true;
17157
17274
  run.tail.append("\u25CF \u6536\u5230\u505C\u6B62\u6307\u4EE4\uFF0C\u6B63\u5728\u7ED3\u675F\u8FDB\u7A0B\u6811\n");
17158
17275
  this.logger.info("run-cancel", { runId, pid: run.pid });
@@ -17175,6 +17292,7 @@ var RunSupervisor = class {
17175
17292
  return { ...read, status: this.active.has(target.runId) ? "running" : "finished", run_id: target.runId };
17176
17293
  }
17177
17294
  async disposeAll() {
17295
+ for (const entry of this.preparing.values()) entry.controller.abort();
17178
17296
  await Promise.all([...this.active.values()].map((run) => {
17179
17297
  run.cancelRequested = true;
17180
17298
  return killProcessTree(run.pid, 1e3);
@@ -17200,24 +17318,29 @@ var RunSupervisor = class {
17200
17318
  if (!input) throw new Error("\u6CA1\u6709\u914D\u7F6E\u5141\u8BB8\u7684\u5DE5\u4F5C\u76EE\u5F55");
17201
17319
  let candidate;
17202
17320
  try {
17203
- candidate = await realpath(isAbsolute2(input) ? input : resolve3(input));
17321
+ candidate = await realpath2(isAbsolute3(input) ? input : resolve3(input));
17204
17322
  } catch {
17205
17323
  throw new Error(`\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${input}`);
17206
17324
  }
17207
17325
  for (const root of this.config.allowedRoots) {
17208
- const allowed = await realpath(root).catch(() => resolve3(root));
17209
- const segment = relative(allowed, candidate);
17210
- if (segment === "" || !segment.startsWith("..") && !isAbsolute2(segment)) return candidate;
17326
+ const allowed = await realpath2(root).catch(() => resolve3(root));
17327
+ const segment = relative2(allowed, candidate);
17328
+ if (segment === "" || !segment.startsWith("..") && !isAbsolute3(segment)) return candidate;
17211
17329
  }
17212
17330
  throw new Error(`\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5728\u5141\u8BB8\u8303\u56F4\u5185\uFF1A${input}`);
17213
17331
  }
17332
+ async prepareTaskFiles(payload, signal2 = AbortSignal.timeout(15 * 6e4), workdir) {
17333
+ if (!payload.workdir) throw new Error("\u8BF7\u5148\u914D\u7F6E\u8BBE\u5907\u9ED8\u8BA4\u76EE\u5F55\u6216\u5DE5\u4F4D\u5DE5\u4F5C\u76EE\u5F55");
17334
+ if (!this.reporter.taskFile) throw new Error("\u4EFB\u52A1\u9644\u4EF6\u4E0B\u8F7D\u672A\u914D\u7F6E");
17335
+ return prepareTaskAttachments(payload, workdir ?? await this.validateWorkdir(payload.workdir), this.reporter.taskFile, AbortSignal.any([signal2, AbortSignal.timeout(15 * 6e4)]));
17336
+ }
17214
17337
  };
17215
17338
  function errorMessage(error51) {
17216
17339
  return error51 instanceof Error ? error51.message : String(error51);
17217
17340
  }
17218
17341
  function sanitizeChatEvent(event) {
17219
17342
  const sanitize = (value) => {
17220
- if (typeof value === "string") return sanitizeTerminalLine(value);
17343
+ if (typeof value === "string") return sanitizeChatText(value);
17221
17344
  if (Array.isArray(value)) return value.map(sanitize);
17222
17345
  if (value && typeof value === "object") {
17223
17346
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
@@ -17226,12 +17349,16 @@ function sanitizeChatEvent(event) {
17226
17349
  };
17227
17350
  return {
17228
17351
  ...event,
17229
- ...event.text !== void 0 ? { text: sanitizeTerminalLine(event.text) } : {},
17352
+ ...event.text !== void 0 ? { text: sanitizeChatText(event.text) } : {},
17230
17353
  ...event.item ? { item: sanitize(event.item) } : {}
17231
17354
  };
17232
17355
  }
17233
17356
 
17234
17357
  // src/transport/gateway-client.ts
17358
+ import { createHash as createHash3 } from "node:crypto";
17359
+ import { createWriteStream } from "node:fs";
17360
+ import { Readable, Transform } from "node:stream";
17361
+ import { pipeline } from "node:stream/promises";
17235
17362
  var GatewayError = class extends Error {
17236
17363
  constructor(message, status) {
17237
17364
  super(message);
@@ -17313,19 +17440,46 @@ var GatewayClient = class {
17313
17440
  runStarted(runId, body) {
17314
17441
  return this.request("POST", `/runs/${encodeURIComponent(runId)}/started`, body, 15e3);
17315
17442
  }
17443
+ async taskFile(runId, fileId, destination, size, sha256, signal2) {
17444
+ const response = await fetch(`${this.config.serverUrl}/api/agent/runs/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileId)}`, {
17445
+ headers: { authorization: `Bearer ${this.config.token}` },
17446
+ redirect: "error",
17447
+ signal: AbortSignal.any([signal2, AbortSignal.timeout(15 * 6e4)])
17448
+ });
17449
+ if (!response.ok || !response.body) throw new GatewayError("\u65E0\u6CD5\u8BFB\u53D6\u4EFB\u52A1\u9644\u4EF6\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u662F\u5426\u5DF2\u5220\u9664\u53CA\u8BBE\u5907\u51ED\u636E", response.status);
17450
+ const declared = response.headers.get("content-length");
17451
+ if (declared !== null && Number(declared) !== size) {
17452
+ await response.body.cancel();
17453
+ throw new Error("\u9644\u4EF6\u5927\u5C0F\u4E0E\u4FDD\u5B58\u7684\u8BB0\u5F55\u4E0D\u4E00\u81F4");
17454
+ }
17455
+ const hash2 = createHash3("sha256");
17456
+ let received = 0;
17457
+ const verifier = new Transform({ transform(chunk, _encoding, callback) {
17458
+ received += chunk.length;
17459
+ if (received > size) {
17460
+ callback(new Error("\u9644\u4EF6\u4E0B\u8F7D\u8D85\u51FA\u8BB0\u5F55\u5927\u5C0F"));
17461
+ return;
17462
+ }
17463
+ hash2.update(chunk);
17464
+ callback(null, chunk);
17465
+ } });
17466
+ const target = createWriteStream(destination, { flags: "wx", mode: 384 });
17467
+ await pipeline(Readable.fromWeb(response.body), verifier, target, { signal: signal2 });
17468
+ if (received !== size || hash2.digest("hex") !== sha256) throw new Error("\u9644\u4EF6\u4E0B\u8F7D\u4E0D\u5B8C\u6574\u6216\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
17469
+ }
17316
17470
  runFinished(runId, body) {
17317
17471
  return this.request("POST", `/runs/${encodeURIComponent(runId)}/finished`, body, 15e3);
17318
17472
  }
17319
17473
  };
17320
17474
 
17321
17475
  // src/workspace/directory-browser.ts
17322
- import { readdir, realpath as realpath2, stat as stat3 } from "node:fs/promises";
17476
+ import { readdir, realpath as realpath3, stat as stat3 } from "node:fs/promises";
17323
17477
  import { homedir as homedir5 } from "node:os";
17324
17478
  import { dirname as dirname6, parse as parse3, resolve as resolve4 } from "node:path";
17325
17479
  var MAX_ENTRIES = 1e3;
17326
17480
  async function listDirectory(requested, allowedRoots) {
17327
17481
  const raw = requested.trim().replace(/^~(?=$|[\\/])/, homedir5()) || homedir5();
17328
- const directory = await realpath2(resolve4(raw)).catch(() => {
17482
+ const directory = await realpath3(resolve4(raw)).catch(() => {
17329
17483
  throw new Error(`\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${requested}`);
17330
17484
  });
17331
17485
  if (!(await stat3(directory)).isDirectory()) throw new Error("\u8FD9\u4E0D\u662F\u4E00\u4E2A\u76EE\u5F55");
@@ -17357,12 +17511,13 @@ async function listDirectory(requested, allowedRoots) {
17357
17511
 
17358
17512
  // src/workspace/workspace-manager.ts
17359
17513
  import { spawn as spawn8 } from "node:child_process";
17360
- import { chmod as chmod3, mkdir as mkdir5, mkdtemp, opendir, realpath as realpath3, rm as rm2, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
17514
+ import { chmod as chmod3, mkdir as mkdir6, mkdtemp, opendir, realpath as realpath4, rm as rm3, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
17361
17515
  import { homedir as homedir6, tmpdir } from "node:os";
17362
- import { isAbsolute as isAbsolute3, join as join7, relative as relative2, resolve as resolve5 } from "node:path";
17516
+ import { isAbsolute as isAbsolute4, join as join8, relative as relative3, resolve as resolve5 } from "node:path";
17363
17517
  var MAX_NODES = 2e3;
17364
17518
  var MAX_DIRECTORY_ENTRIES = 12e3;
17365
17519
  var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
17520
+ ".echo",
17366
17521
  ".git",
17367
17522
  "node_modules",
17368
17523
  ".next",
@@ -17408,12 +17563,12 @@ var WorkspaceManager = class {
17408
17563
  validateRootPath(value) {
17409
17564
  const raw = value.trim().replace(/^~(?=$|[\\/])/, homedir6());
17410
17565
  const root = resolve5(raw);
17411
- if (!isAbsolute3(raw)) throw new Error("\u6839\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84");
17412
- if (relative2(root, resolve5(root, "..")) === "") throw new Error("\u4E0D\u80FD\u628A\u6587\u4EF6\u7CFB\u7EDF\u6839\u76EE\u5F55\u4F5C\u4E3A\u76EE\u5F55\u6839");
17566
+ if (!isAbsolute4(raw)) throw new Error("\u6839\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84");
17567
+ if (relative3(root, resolve5(root, "..")) === "") throw new Error("\u4E0D\u80FD\u628A\u6587\u4EF6\u7CFB\u7EDF\u6839\u76EE\u5F55\u4F5C\u4E3A\u76EE\u5F55\u6839");
17413
17568
  return root;
17414
17569
  }
17415
17570
  async syncTree(rootPath, request) {
17416
- await mkdir5(rootPath, { recursive: true });
17571
+ await mkdir6(rootPath, { recursive: true });
17417
17572
  await addAllowedRoot(this.config, rootPath);
17418
17573
  const results = [];
17419
17574
  const requestedPaths = /* @__PURE__ */ new Set();
@@ -17437,7 +17592,7 @@ var WorkspaceManager = class {
17437
17592
  });
17438
17593
  continue;
17439
17594
  }
17440
- await mkdir5(target, { recursive: true });
17595
+ await mkdir6(target, { recursive: true });
17441
17596
  results.push({
17442
17597
  relative_path: relativePath,
17443
17598
  status: "created",
@@ -17486,7 +17641,7 @@ var WorkspaceManager = class {
17486
17641
  }
17487
17642
  if (!metadata) {
17488
17643
  if (!gitUrl) throw new Error("A missing repository needs a Git remote URL");
17489
- await mkdir5(resolve5(target, ".."), { recursive: true });
17644
+ await mkdir6(resolve5(target, ".."), { recursive: true });
17490
17645
  const cloneArgs = branch ? ["clone", "--branch", branch, "--single-branch", gitUrl, target] : ["clone", gitUrl, target];
17491
17646
  await this.runGit(cloneArgs, rootPath, timeoutSeconds, false, gitEnvironment);
17492
17647
  created = true;
@@ -17580,13 +17735,13 @@ var WorkspaceManager = class {
17580
17735
  };
17581
17736
  }
17582
17737
  async scanTree(rootPath, maxDepth, scanRoots) {
17583
- const resolvedRoot = await realpath3(rootPath);
17738
+ const resolvedRoot = await realpath4(rootPath);
17584
17739
  const rootMetadata = await stat4(resolvedRoot);
17585
17740
  if (!rootMetadata.isDirectory()) throw new Error("The workspace root is not a directory");
17586
17741
  const nodes = [];
17587
17742
  let entriesRead = 0;
17588
17743
  const appendNode = async (target) => {
17589
- const relativePath = relative2(resolvedRoot, target).replaceAll("\\", "/");
17744
+ const relativePath = relative3(resolvedRoot, target).replaceAll("\\", "/");
17590
17745
  if (!relativePath || !this.isSafeRelativePath(relativePath)) return void 0;
17591
17746
  const git = await this.readGitMetadata(target);
17592
17747
  if (git) {
@@ -17670,8 +17825,8 @@ var WorkspaceManager = class {
17670
17825
  if (result.exitCode !== 0 || !topLevel) return false;
17671
17826
  try {
17672
17827
  const [resolvedDirectory, resolvedTopLevel] = await Promise.all([
17673
- realpath3(directory),
17674
- realpath3(topLevel)
17828
+ realpath4(directory),
17829
+ realpath4(topLevel)
17675
17830
  ]);
17676
17831
  return resolvedDirectory === resolvedTopLevel;
17677
17832
  } catch {
@@ -17691,8 +17846,8 @@ var WorkspaceManager = class {
17691
17846
  }
17692
17847
  resolveWithinRoot(rootPath, relativePath) {
17693
17848
  const target = resolve5(rootPath, relativePath);
17694
- const relativeToRoot = relative2(rootPath, target);
17695
- if (relativeToRoot.startsWith("..") || isAbsolute3(relativeToRoot)) {
17849
+ const relativeToRoot = relative3(rootPath, target);
17850
+ if (relativeToRoot.startsWith("..") || isAbsolute4(relativeToRoot)) {
17696
17851
  throw new Error("A workspace path must remain under the configured workspace root");
17697
17852
  }
17698
17853
  return target;
@@ -17733,13 +17888,13 @@ var WorkspaceManager = class {
17733
17888
  }
17734
17889
  async createGitAuthEnvironment(auth) {
17735
17890
  if (!auth) return void 0;
17736
- const directory = await mkdtemp(join7(tmpdir(), "toc-ocra-git-auth-"));
17891
+ const directory = await mkdtemp(join8(tmpdir(), "toc-ocra-git-auth-"));
17737
17892
  const isWindows = process.platform === "win32";
17738
- const scriptPath = join7(directory, isWindows ? "askpass.cmd" : "askpass.sh");
17893
+ const scriptPath = join8(directory, isWindows ? "askpass.cmd" : "askpass.sh");
17739
17894
  let script;
17740
17895
  if (isWindows) {
17741
- const helperPath = join7(directory, "askpass.cjs");
17742
- await writeFile5(
17896
+ const helperPath = join8(directory, "askpass.cjs");
17897
+ await writeFile6(
17743
17898
  helperPath,
17744
17899
  'const prompt = process.argv.slice(2).join(" ");\nprocess.stdout.write(/username/i.test(prompt) ? process.env.ECHO_GIT_USERNAME ?? "" : process.env.ECHO_GIT_PASSWORD ?? "");\n',
17745
17900
  { mode: 448 }
@@ -17748,7 +17903,7 @@ var WorkspaceManager = class {
17748
17903
  } else {
17749
17904
  script = '#!/bin/sh\ncase "$1" in *Username*|*username*) printf "%s\\n" "$ECHO_GIT_USERNAME" ;; *) printf "%s\\n" "$ECHO_GIT_PASSWORD" ;; esac\n';
17750
17905
  }
17751
- await writeFile5(scriptPath, script, { mode: 448 });
17906
+ await writeFile6(scriptPath, script, { mode: 448 });
17752
17907
  if (!isWindows) await chmod3(scriptPath, 448);
17753
17908
  return {
17754
17909
  env: {
@@ -17759,7 +17914,7 @@ var WorkspaceManager = class {
17759
17914
  ECHO_GIT_USERNAME: auth.username,
17760
17915
  ECHO_GIT_PASSWORD: auth.password
17761
17916
  },
17762
- cleanup: () => rm2(directory, { recursive: true, force: true })
17917
+ cleanup: () => rm3(directory, { recursive: true, force: true })
17763
17918
  };
17764
17919
  }
17765
17920
  message(error51) {
@@ -17784,6 +17939,7 @@ var RunStartPayloadSchema = external_exports.object({
17784
17939
  prompt: external_exports.string().max(512 * 1024),
17785
17940
  chat_session_id: external_exports.string().max(64).optional(),
17786
17941
  attachments: external_exports.array(external_exports.object({ media_id: external_exports.string().min(1).max(64), name: external_exports.string().max(200), kind: external_exports.enum(["image", "file"]) })).max(16).optional(),
17942
+ task_attachments: external_exports.array(external_exports.object({ file_id: external_exports.uuid(), name: external_exports.string().min(1).max(255), mime: external_exports.string().max(255), size: external_exports.number().int().positive(), sha256: external_exports.string().regex(/^[a-f0-9]{64}$/), prompt_token: external_exports.string().max(100) })).max(16).nullish(),
17787
17943
  session_id: external_exports.string().max(200).optional().default(""),
17788
17944
  model: external_exports.string().max(100).optional().default(""),
17789
17945
  reasoning_effort: external_exports.string().max(40).optional().default(""),
@@ -17854,13 +18010,14 @@ var AgentRuntime = class {
17854
18010
  await this.retry(() => this.client.chatEvents(runId, events), 5);
17855
18011
  },
17856
18012
  chatMedia: (runId, mediaId) => this.client.chatMedia(runId, mediaId),
18013
+ taskFile: (runId, fileId, destination, size, sha256, signal2) => this.client.taskFile(runId, fileId, destination, size, sha256, signal2),
17857
18014
  finished: async (runId, report) => {
17858
18015
  this.pendingReports.set(runId, report);
17859
18016
  await this.flushReport(runId);
17860
18017
  }
17861
18018
  };
17862
18019
  this.workspaces = new WorkspaceManager(config2, this.logger);
17863
- this.orphans = new OrphanRegistry(join8(config2.dataDir, chatOnly ? "chat-active-runs.json" : "active-runs.json"));
18020
+ this.orphans = new OrphanRegistry(join9(config2.dataDir, chatOnly ? "chat-active-runs.json" : "active-runs.json"));
17864
18021
  this.runs = new RunSupervisor(config2, this.logger, reporter, this.orphans);
17865
18022
  }
17866
18023
  config;
@@ -17873,7 +18030,7 @@ var AgentRuntime = class {
17873
18030
  workspaces;
17874
18031
  stopController = new AbortController();
17875
18032
  startedAt = (/* @__PURE__ */ new Date()).toISOString();
17876
- chatRunnerId = randomUUID2();
18033
+ chatRunnerId = randomUUID3();
17877
18034
  chatReady = false;
17878
18035
  chatLeaseInitialized = false;
17879
18036
  chatError;
@@ -18014,6 +18171,7 @@ var AgentRuntime = class {
18014
18171
  return {
18015
18172
  providers: snapshot.providers.map((p) => ({ id: p.id, installed: p.installed, runnable: p.available, version: p.version ?? "", path: p.resolvedPath ?? "" })),
18016
18173
  cli_chat: { version: 1, providers: CHAT_PROVIDERS.filter((id) => snapshot.detectedIds.includes(id)) },
18174
+ task_attachments: { version: 1 },
18017
18175
  detected_ids: snapshot.detectedIds,
18018
18176
  refreshed_at: snapshot.refreshedAt
18019
18177
  };
@@ -18067,6 +18225,11 @@ var AgentRuntime = class {
18067
18225
  }
18068
18226
  async execute(command) {
18069
18227
  switch (command.type) {
18228
+ case "run.files.prepare": {
18229
+ const payload = RunStartPayloadSchema.parse(command.payload);
18230
+ const prompt = await this.runs.prepareTaskFiles(payload);
18231
+ return { success: true, result: { prompt } };
18232
+ }
18070
18233
  case "chat.run.start":
18071
18234
  case "run.start": {
18072
18235
  const payload = RunStartPayloadSchema.parse(command.payload);
@@ -18298,7 +18461,7 @@ var AgentService = class {
18298
18461
  }
18299
18462
  controlApi() {
18300
18463
  return {
18301
- info: () => ({ name: "toc-ocra", version: this.version, protocol: CONTROL_PROTOCOL_VERSION, paired: Boolean(this.config.token) }),
18464
+ info: () => ({ name: "toc-ocra", version: this.version, protocol: CONTROL_PROTOCOL_VERSION, paired: Boolean(this.config.token), capabilities: { task_attachments: { version: 1 } } }),
18302
18465
  pair: (code) => this.pairing.pair(code),
18303
18466
  authorize: (header) => this.pairing.authorize(header),
18304
18467
  status: () => this.status(),
@@ -18401,7 +18564,7 @@ function preferSystemNode() {
18401
18564
  const names = process.platform === "win32" ? ["node.exe"] : ["node"];
18402
18565
  for (const directory of (process.env.PATH ?? "").split(delimiter5)) {
18403
18566
  for (const name of names) {
18404
- const candidate = directory && join9(directory, name);
18567
+ const candidate = directory && join10(directory, name);
18405
18568
  if (candidate && existsSync6(candidate)) {
18406
18569
  process.env.TOC_OCRA_NODE_PATH = candidate;
18407
18570
  return;
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { dirname, resolve } from 'node:path';
4
+ import { basename, dirname, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { defaultConfigPath, enrollDevice, loadAgentConfig, loadBaseConfig, redactedConfig } from './config.js';
7
7
  import { readConnectionCode } from './control/pairing.js';
@@ -28,7 +28,7 @@ async function doctor() {
28
28
  process.exitCode = 1;
29
29
  }
30
30
  /** Prints the connection code of the running service, for the plugin. */
31
- function code() {
31
+ async function code() {
32
32
  const config = loadBaseConfig();
33
33
  const current = readConnectionCode(config.dataDir);
34
34
  if (!current) {
@@ -37,6 +37,23 @@ function code() {
37
37
  return;
38
38
  }
39
39
  process.stdout.write(`插件连接码:${current}\n本机服务:http://${config.healthHost}:${config.healthPort}\n`);
40
+ try {
41
+ const response = await fetch(`http://${config.healthHost}:${config.healthPort}/control/info`, { signal: AbortSignal.timeout(3_000), redirect: 'error' });
42
+ if (!response.ok)
43
+ throw new Error(`HTTP ${response.status}`);
44
+ const info = await response.json();
45
+ if (info.name !== PACKAGE_NAME || typeof info.version !== 'string')
46
+ throw new Error('本机服务信息无效');
47
+ const installed = agentVersion();
48
+ process.stdout.write(`当前运行服务版本:${info.version}\n命令行安装版本:${installed}\n`);
49
+ if (info.version !== installed)
50
+ process.stdout.write('后台服务仍在运行另一版本。请在本机任务结束后执行 toc-ocra service:install 重启服务。\n');
51
+ if ((info.capabilities?.task_attachments?.version ?? 0) < 1)
52
+ process.stdout.write('当前服务未声明任务附件下载能力;若发送附件被拒绝,请执行 toc-ocra update 升级并重启服务。\n');
53
+ }
54
+ catch {
55
+ process.stderr.write('未能读取当前运行服务版本;连接码不代表设备已连接到后端,请检查本机服务状态。\n');
56
+ }
40
57
  }
41
58
  async function detect() {
42
59
  const hydration = await hydrateShellPath();
@@ -89,8 +106,16 @@ function manageService(action, args) {
89
106
  return true;
90
107
  }
91
108
  function update(args) {
109
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
110
+ const modules = dirname(packageRoot);
111
+ if (basename(modules) !== 'node_modules' || (process.platform !== 'win32' && basename(dirname(modules)) !== 'lib')) {
112
+ throw new Error('当前从源码或非 npm 全局目录运行,请先更新这份源码并构建,再重新安装对应后台服务');
113
+ }
114
+ // npm on PATH can belong to a different Node installation (e.g. Homebrew
115
+ // versus nvm). Update the package that this command actually loaded.
116
+ const prefix = process.platform === 'win32' ? dirname(modules) : dirname(dirname(modules));
92
117
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
93
- const install = spawnSync(npmCommand, ['install', '-g', `${PACKAGE_NAME}@latest`], { stdio: 'inherit', shell: false });
118
+ const install = spawnSync(npmCommand, ['install', '-g', '--prefix', prefix, `${PACKAGE_NAME}@latest`], { stdio: 'inherit', shell: false });
94
119
  if (install.error)
95
120
  throw install.error;
96
121
  if (install.status !== 0) {
@@ -132,12 +157,7 @@ else if (command === 'enroll')
132
157
  else if (command === 'setup')
133
158
  void setup(rest).catch(fail('安装失败'));
134
159
  else if (command === 'code') {
135
- try {
136
- code();
137
- }
138
- catch (error) {
139
- fail('code failed')(error);
140
- }
160
+ void code().catch(fail('code failed'));
141
161
  }
142
162
  else if (command === 'service:install' || command === 'service:uninstall') {
143
163
  try {