@zqagent/core 0.2.14 → 0.2.16

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.
@@ -568,9 +568,25 @@ var init_wire = __esm({
568
568
 
569
569
  // src/cli/index.ts
570
570
  init_cjs_shims();
571
+
572
+ // src/cli/quietDeprecations.ts
573
+ init_cjs_shims();
574
+ var IGNORED_WARNING_CODES = /* @__PURE__ */ new Set(["DEP0040"]);
575
+ function shouldSilenceWarning(warning) {
576
+ const code = warning?.code;
577
+ return typeof code === "string" && IGNORED_WARNING_CODES.has(code);
578
+ }
579
+ var listeners = process.listeners("warning");
580
+ process.removeAllListeners("warning");
581
+ process.on("warning", (warning) => {
582
+ if (shouldSilenceWarning(warning)) return;
583
+ for (const listener of listeners) listener(warning);
584
+ });
585
+
586
+ // src/cli/index.ts
571
587
  var import_config = require("dotenv/config");
572
588
  var fs34 = __toESM(require("fs"), 1);
573
- var readline3 = __toESM(require("readline"), 1);
589
+ var readline4 = __toESM(require("readline"), 1);
574
590
  var path43 = __toESM(require("path"), 1);
575
591
  var import_child_process = require("child_process");
576
592
  var import_util = require("util");
@@ -1097,13 +1113,13 @@ function textOf(content) {
1097
1113
  }
1098
1114
  return content != null ? String(content) : "";
1099
1115
  }
1100
- function extractUserMessages(span, clip = USER_MESSAGE_CLIP) {
1116
+ function extractUserMessages(span, clip2 = USER_MESSAGE_CLIP) {
1101
1117
  const out = [];
1102
1118
  for (const m of span) {
1103
1119
  if (m.role !== "user") continue;
1104
1120
  const text = textOf(m.content).replace(/\s+/g, " ").trim();
1105
1121
  if (!text) continue;
1106
- out.push(text.length > clip ? text.slice(0, clip - 1) + "\u2026" : text);
1122
+ out.push(text.length > clip2 ? text.slice(0, clip2 - 1) + "\u2026" : text);
1107
1123
  }
1108
1124
  return out;
1109
1125
  }
@@ -1937,6 +1953,21 @@ var TurnEngine = class _TurnEngine {
1937
1953
  queueSteering(text) {
1938
1954
  this._steering.push(text);
1939
1955
  }
1956
+ /**
1957
+ * 撤销一条尚未注入的 steering。
1958
+ *
1959
+ * 按文本删第一条匹配项:调用方(SessionManager)已按索引删掉了自己那份队列,
1960
+ * 这里只需保证引擎不再注入同样的内容。用文本而非索引,是因为引擎会在迭代
1961
+ * 间隙批量清空 `_steering`,索引可能已经漂移;文本相同则删哪条都等价。
1962
+ *
1963
+ * @returns 是否真的删掉了(已注入的项返回 false,调用方据此提示用户)
1964
+ */
1965
+ removeSteering(text) {
1966
+ const i = this._steering.indexOf(text);
1967
+ if (i < 0) return false;
1968
+ this._steering.splice(i, 1);
1969
+ return true;
1970
+ }
1940
1971
  // ===================== 主入口 =====================
1941
1972
  /**
1942
1973
  * 运行一轮 Turn
@@ -10514,13 +10545,14 @@ function renderTerminalTutorial() {
10514
10545
  // src/cli/link.ts
10515
10546
  init_cjs_shims();
10516
10547
  var path27 = __toESM(require("path"), 1);
10517
- var readline2 = __toESM(require("readline"), 1);
10548
+ var readline3 = __toESM(require("readline"), 1);
10518
10549
 
10519
10550
  // src/server/store.ts
10520
10551
  init_cjs_shims();
10521
10552
  var fs17 = __toESM(require("fs"), 1);
10522
10553
  var fsp = __toESM(require("fs/promises"), 1);
10523
10554
  var path19 = __toESM(require("path"), 1);
10555
+ var readline = __toESM(require("readline"), 1);
10524
10556
  var import_crypto = require("crypto");
10525
10557
 
10526
10558
  // src/server/dbgTrace.ts
@@ -10740,6 +10772,170 @@ var Store = class {
10740
10772
  }
10741
10773
  }
10742
10774
  }
10775
+ /**
10776
+ * 读取事件「一页」:从文件末尾向前,按字节预算取最近的事件。
10777
+ *
10778
+ * - `before` 省略:取最新的 maxBytes 字节
10779
+ * - `before` 指定:只取 seq < before 的事件(向前翻页)
10780
+ *
10781
+ * 为什么不能复用 loadEventsTail:后者只能从文件末尾读固定窗口,
10782
+ * 无法「向前翻页」,而控制台需要滚到顶部继续加载更早的对话。
10783
+ * 这里按块从后往前扫,读到预算或文件头即停;翻了多页之后每次
10784
+ * before 都紧邻上一页,实际只需读 1~2 块。
10785
+ *
10786
+ * 返回按 seq 升序;`hasMore` 表示本页最早一条之前还有更早的事件。
10787
+ */
10788
+ loadEventPage(tenantId, sessionId, opts) {
10789
+ const p = path19.join(this.sessionDir(tenantId, sessionId), "events.jsonl");
10790
+ const { before, maxBytes } = opts;
10791
+ const __t0 = dbgNow();
10792
+ let fd = null;
10793
+ try {
10794
+ const size = fs17.statSync(p).size;
10795
+ if (size === 0) return { events: [], hasMore: false };
10796
+ fd = fs17.openSync(p, "r");
10797
+ const CHUNK = 256 * 1024;
10798
+ let pos = size;
10799
+ const kept = [];
10800
+ let bytes = 0;
10801
+ let hasMore = false;
10802
+ let done = false;
10803
+ while (pos > 0 && !done) {
10804
+ const start = Math.max(0, pos - CHUNK);
10805
+ const len = pos - start;
10806
+ const buf = Buffer.allocUnsafe(len);
10807
+ const read2 = fs17.readSync(fd, buf, 0, len, start);
10808
+ const rows = buf.subarray(0, read2).toString("utf-8").split("\n");
10809
+ if (start > 0) rows.shift();
10810
+ for (let i = rows.length - 1; i >= 0; i--) {
10811
+ const line = rows[i];
10812
+ if (!line) continue;
10813
+ let entry;
10814
+ try {
10815
+ entry = JSON.parse(line);
10816
+ } catch {
10817
+ continue;
10818
+ }
10819
+ if (!Number.isFinite(Number(entry.seq))) continue;
10820
+ if (before !== void 0 && Number(entry.seq) >= before) continue;
10821
+ const sz = Buffer.byteLength(line, "utf-8") + 1;
10822
+ if (bytes > 0 && bytes + sz > maxBytes) {
10823
+ hasMore = true;
10824
+ done = true;
10825
+ break;
10826
+ }
10827
+ bytes += sz;
10828
+ kept.unshift(entry);
10829
+ }
10830
+ pos = start;
10831
+ }
10832
+ dbgMs(
10833
+ "store.loadEventPage",
10834
+ __t0,
10835
+ // [dbg]
10836
+ `size=${size} before=${before ?? "-"} out=${kept.length} bytes\u2248${bytes} more=${hasMore}`
10837
+ );
10838
+ return { events: kept, hasMore };
10839
+ } catch {
10840
+ return { events: [], hasMore: false };
10841
+ } finally {
10842
+ if (fd !== null) {
10843
+ try {
10844
+ fs17.closeSync(fd);
10845
+ } catch {
10846
+ }
10847
+ }
10848
+ }
10849
+ }
10850
+ // ===================== 轮次索引 =====================
10851
+ /** 轮次索引文件路径 */
10852
+ turnsFile(tenantId, sessionId) {
10853
+ return path19.join(this.sessionDir(tenantId, sessionId), "turns.ndjson");
10854
+ }
10855
+ /**
10856
+ * 追加一轮的最新快照(同一 startSeq 后写者胜出)。
10857
+ *
10858
+ * 走与 session.json 相同的每文件写链:索引按「开轮 → 补充消息 → 收轮」
10859
+ * 顺序追加,乱序会让列表读到旧的 running 版本。
10860
+ */
10861
+ appendTurnIndex(tenantId, sessionId, entry) {
10862
+ const dir = this.sessionDir(tenantId, sessionId);
10863
+ fs17.mkdirSync(dir, { recursive: true });
10864
+ const file = this.turnsFile(tenantId, sessionId);
10865
+ const line = JSON.stringify(entry) + "\n";
10866
+ const prev = this.writeChains.get(file) ?? Promise.resolve();
10867
+ const next = prev.then(() => fsp.appendFile(file, line, "utf-8")).catch((err) => {
10868
+ console.error(`[store] appendTurnIndex \u5931\u8D25 ${sessionId}: ${String(err)}`);
10869
+ });
10870
+ this.writeChains.set(file, next);
10871
+ return next;
10872
+ }
10873
+ /**
10874
+ * 读取轮次索引(已按 startSeq 去重取最后一版,升序)。
10875
+ *
10876
+ * 文件很小(一轮约 0.5KB,1000 轮 ≈ 500KB),同步读即可,
10877
+ * 会话详情打开路径不会因历史长度变慢。
10878
+ */
10879
+ loadTurnIndex(tenantId, sessionId) {
10880
+ let raw;
10881
+ try {
10882
+ raw = fs17.readFileSync(this.turnsFile(tenantId, sessionId), "utf-8");
10883
+ } catch {
10884
+ return [];
10885
+ }
10886
+ const byStart = /* @__PURE__ */ new Map();
10887
+ for (const line of raw.split("\n")) {
10888
+ if (!line) continue;
10889
+ try {
10890
+ const e = JSON.parse(line);
10891
+ if (!Number.isFinite(Number(e?.startSeq))) continue;
10892
+ byStart.set(Number(e.startSeq), e);
10893
+ } catch {
10894
+ }
10895
+ }
10896
+ return [...byStart.values()].sort((a, b) => a.startSeq - b.startSeq);
10897
+ }
10898
+ /** 整文件替换轮次索引(重建用,tmp + rename 原子写) */
10899
+ saveTurnIndex(tenantId, sessionId, entries) {
10900
+ const dir = this.sessionDir(tenantId, sessionId);
10901
+ fs17.mkdirSync(dir, { recursive: true });
10902
+ const file = this.turnsFile(tenantId, sessionId);
10903
+ const snapshot = entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : "");
10904
+ const tmp = `${file}.${(0, import_crypto.randomUUID)()}.tmp`;
10905
+ const prev = this.writeChains.get(file) ?? Promise.resolve();
10906
+ const next = prev.then(() => fsp.writeFile(tmp, snapshot, "utf-8")).then(() => fsp.rename(tmp, file)).catch((err) => {
10907
+ console.error(`[store] saveTurnIndex \u5931\u8D25 ${sessionId}: ${String(err)}`);
10908
+ void fsp.rm(tmp, { force: true });
10909
+ });
10910
+ this.writeChains.set(file, next);
10911
+ return next;
10912
+ }
10913
+ /**
10914
+ * 流式读取整份事件日志(重建索引用)。
10915
+ *
10916
+ * 不能用 loadEvents(readFileSync + 逐行 JSON.parse):大会话几十 MB,
10917
+ * 会长时间阻塞事件循环,导致 SSE 心跳 / MQTT 保活断开。
10918
+ */
10919
+ async readEventsAsync(tenantId, sessionId) {
10920
+ const p = path19.join(this.sessionDir(tenantId, sessionId), "events.jsonl");
10921
+ const out = [];
10922
+ try {
10923
+ const rl2 = readline.createInterface({
10924
+ input: fs17.createReadStream(p, { encoding: "utf-8" }),
10925
+ crlfDelay: Infinity
10926
+ });
10927
+ for await (const line of rl2) {
10928
+ if (!line) continue;
10929
+ try {
10930
+ out.push(JSON.parse(line));
10931
+ } catch {
10932
+ }
10933
+ }
10934
+ } catch {
10935
+ return out;
10936
+ }
10937
+ return out;
10938
+ }
10743
10939
  // ===================== 节点身份 & 管理员密钥 =====================
10744
10940
  /** 节点稳定标识:{root}/node.json(首次启动生成后长期固定) */
10745
10941
  loadNodeId() {
@@ -10939,7 +11135,7 @@ function defaultNodeLabel() {
10939
11135
  // src/cli/account-enroll.ts
10940
11136
  init_cjs_shims();
10941
11137
  var path24 = __toESM(require("path"), 1);
10942
- var readline = __toESM(require("readline/promises"), 1);
11138
+ var readline2 = __toESM(require("readline/promises"), 1);
10943
11139
 
10944
11140
  // src/server/projects.ts
10945
11141
  init_cjs_shims();
@@ -11197,6 +11393,19 @@ function addAgg(into, tokens, costUsd) {
11197
11393
  function utcDay(iso) {
11198
11394
  return iso.slice(0, 10);
11199
11395
  }
11396
+ function sumAgg(values) {
11397
+ const out = zeroAgg();
11398
+ for (const v of values) {
11399
+ out.calls += v.calls;
11400
+ out.input += v.input;
11401
+ out.output += v.output;
11402
+ out.cacheRead += v.cacheRead;
11403
+ out.cacheWrite += v.cacheWrite;
11404
+ out.costUsd += v.costUsd;
11405
+ out.unpriced += v.unpriced;
11406
+ }
11407
+ return out;
11408
+ }
11200
11409
  function monthOf(iso) {
11201
11410
  return iso.slice(0, 7);
11202
11411
  }
@@ -11472,6 +11681,23 @@ var UsageLedger = class {
11472
11681
  }
11473
11682
  return all;
11474
11683
  }
11684
+ /**
11685
+ * 按「项目 × 天」聚合(供控制面把消耗拆到每个项目)。
11686
+ *
11687
+ * 汇总文件里只存了按项目、按天两个独立维度,拿不到交叉项,因此这里从
11688
+ * 账本明细现算——调用方(节点上报)只取最近 40 天,读的是 2 个月文件,
11689
+ * 5 分钟一次的成本可忽略。
11690
+ */
11691
+ projectByDay(opts) {
11692
+ const out = {};
11693
+ for (const rec of this.allRecords({ from: opts.from, to: opts.to })) {
11694
+ const day = utcDay(rec.ts);
11695
+ const days = out[rec.projectId] ?? (out[rec.projectId] = {});
11696
+ const agg = days[day] ?? (days[day] = zeroAgg());
11697
+ addAgg(agg, rec, rec.costUsd);
11698
+ }
11699
+ return out;
11700
+ }
11475
11701
  summary(opts) {
11476
11702
  this.ensureInit();
11477
11703
  const currency = (opts.currency ?? "USD").toUpperCase();
@@ -11771,6 +11997,141 @@ var EVENT_BUFFER_MAX = 2e3;
11771
11997
  var EVENTS_ACK_MAX_BYTES = 512 * 1024;
11772
11998
  var APPROVAL_TIMEOUT_MS = (Number(process.env.APPROVAL_TIMEOUT_SECONDS) || 15 * 60) * 1e3;
11773
11999
  var LAST_USER_MESSAGE_MAX = 200;
12000
+ var TURN_TEXT_MAX = 500;
12001
+ var TURN_REPLY_MAX_BYTES = 256 * 1024;
12002
+ var TURN_REPLY_MAX_TEXT = 256 * 1024;
12003
+ var TURNS_DEFAULT_LIMIT = 20;
12004
+ function clip(text, max = LAST_USER_MESSAGE_MAX) {
12005
+ const s = text ?? "";
12006
+ return s.length > max ? `${s.slice(0, max)}\u2026` : s;
12007
+ }
12008
+ function asNum(v) {
12009
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
12010
+ }
12011
+ function newTurnEntry(ev, input) {
12012
+ return {
12013
+ v: 1,
12014
+ startSeq: ev.seq,
12015
+ endSeq: ev.seq,
12016
+ startedAt: ev.ts,
12017
+ endedAt: null,
12018
+ status: "running",
12019
+ inputs: [{ seq: ev.seq, ts: ev.ts, text: clip(input, TURN_TEXT_MAX) }],
12020
+ replyPreview: "",
12021
+ producedFiles: [],
12022
+ llmCalls: 0,
12023
+ toolCalls: 0,
12024
+ tokensIn: 0,
12025
+ tokensOut: 0
12026
+ };
12027
+ }
12028
+ function applyTurnEvent(turn, ev, toolSeen) {
12029
+ const d = ev.data ?? {};
12030
+ turn.endSeq = ev.seq;
12031
+ switch (ev.type) {
12032
+ case "queue_steering": {
12033
+ turn.inputs.push({
12034
+ seq: ev.seq,
12035
+ ts: ev.ts,
12036
+ text: clip(String(d["text"] ?? ""), TURN_TEXT_MAX)
12037
+ });
12038
+ break;
12039
+ }
12040
+ case "assistant_message": {
12041
+ turn.llmCalls += 1;
12042
+ const text = String(d["text"] ?? "");
12043
+ if (text) {
12044
+ turn.replyPreview = clip(text);
12045
+ turn.replySeq = ev.seq;
12046
+ }
12047
+ const u = d["usage"];
12048
+ if (u && typeof u === "object") {
12049
+ const usage = u;
12050
+ turn.tokensIn += asNum(usage["input"]);
12051
+ turn.tokensOut += asNum(usage["output"]);
12052
+ }
12053
+ break;
12054
+ }
12055
+ case "tool_started":
12056
+ case "tool_finished": {
12057
+ const key = String(d["reqId"] ?? "") || `${String(d["name"] ?? "")}:${ev.seq}`;
12058
+ if (!toolSeen.has(key)) {
12059
+ toolSeen.add(key);
12060
+ turn.toolCalls += 1;
12061
+ }
12062
+ break;
12063
+ }
12064
+ case "error": {
12065
+ turn.error = clip(String(d["message"] ?? d["error"] ?? ""));
12066
+ break;
12067
+ }
12068
+ default:
12069
+ break;
12070
+ }
12071
+ }
12072
+ function closeTurnEntry(turn, ev) {
12073
+ const d = ev.data ?? {};
12074
+ turn.endSeq = ev.seq;
12075
+ turn.endedAt = ev.ts;
12076
+ const status = String(d["status"] ?? "");
12077
+ turn.status = ev.type === "interrupted" ? status || "interrupted" : status || "completed";
12078
+ const files = d["produced_files"];
12079
+ if (Array.isArray(files)) {
12080
+ turn.producedFiles = files.map((x) => String(x ?? "")).filter(Boolean);
12081
+ }
12082
+ if (typeof d["iterations"] === "number") turn.iterations = d["iterations"];
12083
+ const startMs = Date.parse(turn.startedAt);
12084
+ const endMs = Date.parse(ev.ts);
12085
+ if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
12086
+ turn.durationMs = endMs - startMs;
12087
+ }
12088
+ }
12089
+ function buildTurnIndex(events) {
12090
+ const out = [];
12091
+ let cur = null;
12092
+ let toolSeen = /* @__PURE__ */ new Set();
12093
+ for (const ev of events) {
12094
+ if (ev.type === "turn_start") {
12095
+ cur = newTurnEntry(ev, String(ev.data?.["input"] ?? ""));
12096
+ toolSeen = /* @__PURE__ */ new Set();
12097
+ out.push(cur);
12098
+ continue;
12099
+ }
12100
+ if (!cur) continue;
12101
+ if (ev.type === "turn_end" || ev.type === "interrupted") {
12102
+ closeTurnEntry(cur, ev);
12103
+ cur = null;
12104
+ continue;
12105
+ }
12106
+ applyTurnEvent(cur, ev, toolSeen);
12107
+ }
12108
+ for (const t of out) {
12109
+ if (!t.endedAt && t.error) t.status = "error";
12110
+ }
12111
+ return out;
12112
+ }
12113
+ function summaryFromEntry(t) {
12114
+ return {
12115
+ startSeq: t.startSeq,
12116
+ endSeq: t.endSeq,
12117
+ startedAt: t.startedAt,
12118
+ endedAt: t.endedAt,
12119
+ status: t.status,
12120
+ input: t.inputs[0]?.text ?? "",
12121
+ reply: t.replyPreview,
12122
+ llmCalls: t.llmCalls,
12123
+ toolCalls: t.toolCalls,
12124
+ tokensIn: t.tokensIn,
12125
+ tokensOut: t.tokensOut,
12126
+ iterations: t.iterations,
12127
+ durationMs: t.durationMs,
12128
+ error: t.error,
12129
+ inputs: t.inputs,
12130
+ userCount: t.inputs.length,
12131
+ replySeq: t.replySeq,
12132
+ producedFiles: t.producedFiles
12133
+ };
12134
+ }
11774
12135
  function isAffirmative(answer) {
11775
12136
  const a = answer.trim().toLowerCase();
11776
12137
  if (!a) return false;
@@ -11795,6 +12156,10 @@ var SessionManager = class {
11795
12156
  opts;
11796
12157
  runtimes = /* @__PURE__ */ new Map();
11797
12158
  // sessionId → runtime
12159
+ /** 轮次索引的内存副本(运行中的轮比磁盘索引新;重启后为空) */
12160
+ turnIndexCache = /* @__PURE__ */ new Map();
12161
+ /** 进行中的索引重建任务(并发请求合并到同一个) */
12162
+ turnIndexBuilds = /* @__PURE__ */ new Map();
11798
12163
  /** 全局通知 SSE 订阅者(控制台通知中心) */
11799
12164
  notificationSubs = /* @__PURE__ */ new Set();
11800
12165
  /**
@@ -11849,6 +12214,17 @@ var SessionManager = class {
11849
12214
  rt.status = status;
11850
12215
  this._emitNotify(rt, "session_status", this._statusData(rt));
11851
12216
  }
12217
+ /**
12218
+ * 通知里的「最近一条用户消息」:截断到 LAST_USER_MESSAGE_MAX。
12219
+ *
12220
+ * 三处共用(session_status / turn_completed / turn_stopped),避免各自写一遍
12221
+ * 截断规则后长度不一致——控制台弹窗与任务列表会显示同一句话的两个版本。
12222
+ */
12223
+ _lastMessage(rt) {
12224
+ const text = (rt.lastInput ?? "").trim();
12225
+ if (!text) return null;
12226
+ return text.length > LAST_USER_MESSAGE_MAX ? `${text.slice(0, LAST_USER_MESSAGE_MAX)}\u2026` : text;
12227
+ }
11852
12228
  /**
11853
12229
  * session_status 通知载荷:状态 + 最近一条用户消息(截断)。
11854
12230
  *
@@ -11857,10 +12233,24 @@ var SessionManager = class {
11857
12233
  */
11858
12234
  _statusData(rt) {
11859
12235
  const data = { status: rt.status };
11860
- const text = (rt.lastInput ?? "").trim();
11861
- if (text) {
11862
- data.lastUserMessage = text.length > LAST_USER_MESSAGE_MAX ? `${text.slice(0, LAST_USER_MESSAGE_MAX)}\u2026` : text;
11863
- }
12236
+ if (rt.lastStartedAt) data.lastStartedAt = rt.lastStartedAt;
12237
+ const msg = this._lastMessage(rt);
12238
+ if (msg) data.lastUserMessage = msg;
12239
+ return data;
12240
+ }
12241
+ /**
12242
+ * 任务结束类通知(完成 / 达到迭代上限)的载荷:在 turn_end 数据之外补上
12243
+ * 「会话名 + 最后一条用户消息」。
12244
+ *
12245
+ * 控制台右下角的完成弹窗要说明「哪个会话、用户让它做了什么」。这些字段
12246
+ * 以节点侧为准随通知一起下发,前端就不必依赖快照(nodeInfo / session_status)
12247
+ * 是否已经到达——刚打开页面或通知乱序时才不会退化成只剩一句「任务已完成」。
12248
+ */
12249
+ _turnEndData(rt, eventData, extra) {
12250
+ const data = { ...eventData, ...extra };
12251
+ if (rt.name) data.sessionName = rt.name;
12252
+ const msg = this._lastMessage(rt);
12253
+ if (msg) data.lastUserMessage = msg;
11864
12254
  return data;
11865
12255
  }
11866
12256
  /** 触发通知回调(无 handler 时为空操作,异常隔离不冒泡) */
@@ -12017,6 +12407,7 @@ data: ${JSON.stringify({ sessionId, seq: entry.seq, ts: entry.ts, data: entry.da
12017
12407
  this.runtimes.delete(sessionId);
12018
12408
  }
12019
12409
  this.store.deleteSession(tenantId, sessionId);
12410
+ this.turnIndexCache.delete(sessionId);
12020
12411
  this._publishNotify(tenantId, {
12021
12412
  kind: "session_status",
12022
12413
  sessionId,
@@ -12060,6 +12451,7 @@ data: ${JSON.stringify({ sessionId, seq: entry.seq, ts: entry.ts, data: entry.da
12060
12451
  throw new ApiError(409, "busy", "\u4F1A\u8BDD\u6B63\u5728\u8FD0\u884C\u4E2D");
12061
12452
  }
12062
12453
  rt.engine.queueSteering(input);
12454
+ if (rt.pendingSteers.length === 0) rt.steerBaseInput = rt.lastInput;
12063
12455
  rt.pendingSteers.push(input);
12064
12456
  this._recordLastInput(rt, input);
12065
12457
  return "steered";
@@ -12092,9 +12484,42 @@ data: ${JSON.stringify({ sessionId, seq: entry.seq, ts: entry.ts, data: entry.da
12092
12484
  const rt = this._getOrLoadRuntime(tenant, sessionId);
12093
12485
  if (!rt.engine) throw new ApiError(409, "not_running", "\u4F1A\u8BDD\u672A\u5728\u8FD0\u884C\u4E2D");
12094
12486
  rt.engine.queueSteering(text);
12487
+ if (rt.pendingSteers.length === 0) rt.steerBaseInput = rt.lastInput;
12095
12488
  rt.pendingSteers.push(text);
12096
12489
  this._recordLastInput(rt, text);
12097
12490
  }
12491
+ /**
12492
+ * 撤销一条尚未注入的补充信息(用户反悔)。
12493
+ *
12494
+ * 队列里的项还没进聊天流,此时只能靠中断整个任务来「收回」代价太大。
12495
+ * index 取自 GET /state 的 pendingSteers(与前端展示顺序一致),两侧同时删:
12496
+ * 只删前端那份,引擎下一迭代仍会把它注入对话;只删引擎那份,队列里会残留
12497
+ * 一条永远不注入的条目。
12498
+ *
12499
+ * 若撤的正是「最近用户指令」,把它还原成队列里的上一条、或本轮被覆盖前的
12500
+ * 指令——否则工作台还会显示这条已经被取消的内容。
12501
+ *
12502
+ * @returns 剩余待注入队列(前端据此刷新提示区)
12503
+ */
12504
+ unsteer(tenant, sessionId, index) {
12505
+ const rt = this._getOrLoadRuntime(tenant, sessionId);
12506
+ if (!rt.engine || !rt.runTask) {
12507
+ throw new ApiError(409, "not_running", "\u4F1A\u8BDD\u672A\u5728\u8FD0\u884C\u4E2D");
12508
+ }
12509
+ if (!Number.isInteger(index) || index < 0 || index >= rt.pendingSteers.length) {
12510
+ throw new ApiError(409, "already_injected", "\u8FD9\u6761\u8865\u5145\u4FE1\u606F\u5DF2\u8FDB\u5165\u5BF9\u8BDD\uFF0C\u65E0\u6CD5\u64A4\u9500");
12511
+ }
12512
+ const [removed] = rt.pendingSteers.splice(index, 1);
12513
+ if (removed !== void 0) {
12514
+ rt.engine.removeSteering(removed);
12515
+ if (removed === rt.lastInput) {
12516
+ rt.lastInput = rt.pendingSteers.length > 0 ? rt.pendingSteers[rt.pendingSteers.length - 1] : rt.steerBaseInput;
12517
+ this._persist(rt);
12518
+ this._emitNotify(rt, "session_status", this._statusData(rt));
12519
+ }
12520
+ }
12521
+ return [...rt.pendingSteers];
12522
+ }
12098
12523
  /**
12099
12524
  * 已提交、尚未注入当前任务的补充信息。
12100
12525
  *
@@ -12527,44 +12952,214 @@ data: ${JSON.stringify({ sessionId, seq: entry.seq, ts: entry.ts, data: entry.da
12527
12952
  status: rt.status
12528
12953
  };
12529
12954
  }
12530
- /** 增量事件(since 游标;无内存运行时则从磁盘读全量) */
12531
- eventsSince(tenantId, sessionId, since) {
12955
+ /**
12956
+ * 事件分页(向前 / 向后两种方向)。
12957
+ *
12958
+ * - `since`:取 seq > since(实时增量 / 重连补齐)
12959
+ * - `before`:取 seq < before(控制台滚到顶部加载更早的对话)
12960
+ * - `maxBytes`:单次回执的字节预算(默认沿用 EVENTS_ACK_MAX_BYTES)
12961
+ *
12962
+ * 两者同时给出时按 `since`(向后)处理。`hasMore` 只对向前翻页有意义:
12963
+ * 表示本页最早一条之前还有更早的事件。
12964
+ *
12965
+ * 向前翻页一律走磁盘:内存缓冲只保留最近 EVENT_BUFFER_MAX 条,
12966
+ * 更早的事件只在 events.jsonl 里。
12967
+ */
12968
+ eventsPage(tenantId, sessionId, opts = {}) {
12532
12969
  const __t0 = dbgNow();
12970
+ const maxBytes = opts.maxBytes && opts.maxBytes > 0 ? opts.maxBytes : EVENTS_ACK_MAX_BYTES;
12533
12971
  const rt = this.runtimes.get(sessionId);
12972
+ if (opts.before !== void 0) {
12973
+ const since2 = opts.since ?? 0;
12974
+ const page = this.store.loadEventPage(tenantId, sessionId, {
12975
+ before: opts.before,
12976
+ maxBytes
12977
+ });
12978
+ const ev2 = since2 > 0 ? page.events.filter((e) => e.seq > since2) : page.events;
12979
+ dbgMs(
12980
+ "session.eventsPage(before/\u78C1\u76D8)",
12981
+ __t0,
12982
+ // [dbg]
12983
+ `sid=${sessionId.slice(0, 8)} since=${since2} before=${opts.before} out=${ev2.length} more=${page.hasMore}`
12984
+ );
12985
+ return {
12986
+ events: ev2,
12987
+ latestSeq: rt?.seq ?? page.events.at(-1)?.seq ?? 0,
12988
+ earliestSeq: ev2[0]?.seq ?? 0,
12989
+ hasMore: page.hasMore
12990
+ };
12991
+ }
12992
+ const since = opts.since ?? 0;
12534
12993
  if (rt) {
12535
- const ev2 = this._boundedTail(rt.events.filter((e) => e.seq > since));
12994
+ const ev2 = this._boundedTail(rt.events.filter((e) => e.seq > since), maxBytes);
12536
12995
  dbgMs(
12537
- "session.eventsSince(\u5185\u5B58)",
12996
+ "session.eventsPage(\u5185\u5B58)",
12538
12997
  __t0,
12539
12998
  // [dbg]
12540
12999
  `sid=${sessionId.slice(0, 8)} since=${since} in=${rt.events.length} out=${ev2.length}`
12541
13000
  );
12542
- return { events: ev2, latestSeq: rt.seq };
13001
+ return {
13002
+ events: ev2,
13003
+ latestSeq: rt.seq,
13004
+ earliestSeq: ev2[0]?.seq ?? 0,
13005
+ // 内存缓冲被截断(或 since 早于缓冲起点)时,磁盘上可能还有更早的
13006
+ hasMore: !!ev2.length && ev2[0].seq > (rt.events[0]?.seq ?? 0)
13007
+ };
12543
13008
  }
12544
13009
  const all = this.store.loadEventsTail(tenantId, sessionId, EVENTS_ACK_MAX_BYTES * 2);
12545
- const ev = this._boundedTail(all.filter((e) => e.seq > since));
13010
+ const ev = this._boundedTail(
13011
+ all.filter((e) => e.seq > since),
13012
+ maxBytes
13013
+ );
12546
13014
  dbgMs(
12547
- "session.eventsSince(\u78C1\u76D8)",
13015
+ "session.eventsPage(\u78C1\u76D8)",
12548
13016
  __t0,
12549
13017
  // [dbg]
12550
13018
  `sid=${sessionId.slice(0, 8)} since=${since} loaded=${all.length} out=${ev.length}`
12551
13019
  );
12552
13020
  return {
12553
13021
  events: ev,
12554
- latestSeq: all.at(-1)?.seq ?? 0
13022
+ latestSeq: all.at(-1)?.seq ?? 0,
13023
+ earliestSeq: ev[0]?.seq ?? 0,
13024
+ // 读的是文件尾部窗口:窗口已满说明更早的事件还在文件里
13025
+ hasMore: all.length > 0 && ev.length > 0 && ev[0].seq > all[0].seq
13026
+ };
13027
+ }
13028
+ /**
13029
+ * 兼容旧调用:只按 since 取增量。
13030
+ * @deprecated 新代码用 eventsPage(支持 before 向前翻页)
13031
+ */
13032
+ eventsSince(tenantId, sessionId, since) {
13033
+ const r = this.eventsPage(tenantId, sessionId, { since });
13034
+ return { events: r.events, latestSeq: r.latestSeq };
13035
+ }
13036
+ /**
13037
+ * 「按 turn 汇总」的轻量列表:只回标题与统计,点开某一轮再单独拉该轮回复。
13038
+ *
13039
+ * 数据来自 turns.ndjson 索引(派生自事件流),读取成本与会话历史长度无关:
13040
+ * 老会话首次访问时后台/本请求内重建一次,之后每页只读几 KB。
13041
+ *
13042
+ * - `before`:只返回 startSeq < before 的轮次(向前翻页)
13043
+ * - `turns` 按时间升序;`hasMore` 表示更早还有轮次
13044
+ */
13045
+ async sessionTurns(tenantId, sessionId, opts = {}) {
13046
+ const limit = Math.min(Math.max(opts.limit ?? TURNS_DEFAULT_LIMIT, 1), 100);
13047
+ const entries = await this._ensureTurnIndex(tenantId, sessionId);
13048
+ const before = opts.before;
13049
+ const endIdx = before === void 0 ? entries.length : entries.findIndex((t) => t.startSeq >= before);
13050
+ const end = endIdx < 0 ? entries.length : endIdx;
13051
+ const start = Math.max(0, end - limit);
13052
+ const picked = entries.slice(start, end);
13053
+ return {
13054
+ turns: picked.map(summaryFromEntry),
13055
+ hasMore: start > 0,
13056
+ earliestSeq: picked[0]?.startSeq ?? 0,
13057
+ total: entries.length,
13058
+ userTotal: entries.reduce((n, t) => n + t.inputs.length, 0)
13059
+ };
13060
+ }
13061
+ /**
13062
+ * 某一轮的用户消息与最终回复(含产出文件)。
13063
+ *
13064
+ * 只读两条索引记录 + 尾部一个事件页,不会因该轮工具输出很大而变慢:
13065
+ * 回复正文紧邻 turn_end,按 replySeq 精确读即可。
13066
+ */
13067
+ async sessionTurnReply(tenantId, sessionId, startSeq) {
13068
+ const entries = await this._ensureTurnIndex(tenantId, sessionId);
13069
+ const turn = entries.find((t) => t.startSeq === startSeq);
13070
+ if (!turn) throw new ApiError(404, "not_found", "\u8F6E\u6B21\u4E0D\u5B58\u5728");
13071
+ let reply = null;
13072
+ let truncated = false;
13073
+ if (turn.replySeq !== void 0) {
13074
+ const page = this.store.loadEventPage(tenantId, sessionId, {
13075
+ before: turn.replySeq + 1,
13076
+ maxBytes: TURN_REPLY_MAX_BYTES
13077
+ });
13078
+ const ev = page.events.find((e) => e.seq === turn.replySeq);
13079
+ if (ev && ev.type === "assistant_message") {
13080
+ const text = String(ev.data?.["text"] ?? "");
13081
+ if (text.length > TURN_REPLY_MAX_TEXT) {
13082
+ reply = { seq: ev.seq, ts: ev.ts, text: text.slice(0, TURN_REPLY_MAX_TEXT) };
13083
+ truncated = true;
13084
+ } else {
13085
+ reply = { seq: ev.seq, ts: ev.ts, text };
13086
+ }
13087
+ }
13088
+ }
13089
+ return {
13090
+ startSeq: turn.startSeq,
13091
+ endSeq: turn.endSeq,
13092
+ status: turn.status,
13093
+ startedAt: turn.startedAt,
13094
+ endedAt: turn.endedAt,
13095
+ userMessages: turn.inputs,
13096
+ reply,
13097
+ truncated,
13098
+ producedFiles: turn.producedFiles
12555
13099
  };
12556
13100
  }
13101
+ /**
13102
+ * 取轮次索引;缺失时从事件流重建(同一会话的并发请求共用一个重建任务)。
13103
+ *
13104
+ * 索引是派生数据:损坏/落后都只需重建,不影响会话本身。
13105
+ */
13106
+ /**
13107
+ * 取轮次索引;缺失/落后时从事件流重建(同一会话的并发请求共用一个重建任务)。
13108
+ *
13109
+ * 索引是派生数据:损坏或落后都只需重建,不影响会话本身。
13110
+ */
13111
+ async _ensureTurnIndex(tenantId, sessionId) {
13112
+ let entries = this._mergeTurnIndex(tenantId, sessionId);
13113
+ if (entries.length > 0 && !this.runtimes.has(sessionId)) {
13114
+ const last = entries[entries.length - 1];
13115
+ const tail = this.store.loadEventsTail(tenantId, sessionId, 64 * 1024);
13116
+ const newTurnStarted = tail.some((e) => e.type === "turn_start" && e.seq > last.endSeq);
13117
+ if (newTurnStarted || last.status === "running") entries = [];
13118
+ }
13119
+ if (entries.length > 0) return entries;
13120
+ const pending = this.turnIndexBuilds.get(sessionId);
13121
+ if (pending) return pending;
13122
+ const task = this._rebuildTurnIndex(tenantId, sessionId).finally(() => {
13123
+ this.turnIndexBuilds.delete(sessionId);
13124
+ });
13125
+ this.turnIndexBuilds.set(sessionId, task);
13126
+ return task;
13127
+ }
13128
+ /** 合并磁盘索引与内存索引(内存版本更新,同一 startSeq 以内存为准) */
13129
+ _mergeTurnIndex(tenantId, sessionId) {
13130
+ const byStart = /* @__PURE__ */ new Map();
13131
+ for (const e of this.store.loadTurnIndex(tenantId, sessionId)) byStart.set(e.startSeq, e);
13132
+ for (const e of this.turnIndexCache.get(sessionId) ?? []) byStart.set(e.startSeq, e);
13133
+ return [...byStart.values()].sort((a, b) => a.startSeq - b.startSeq);
13134
+ }
13135
+ /** 从 events.jsonl 顺序流式重建轮次索引(一次性,之后增量维护) */
13136
+ async _rebuildTurnIndex(tenantId, sessionId) {
13137
+ const __t0 = dbgNow();
13138
+ const events = await this.store.readEventsAsync(tenantId, sessionId);
13139
+ const entries = buildTurnIndex(events);
13140
+ if (entries.length > 0) {
13141
+ await this.store.saveTurnIndex(tenantId, sessionId, entries);
13142
+ if (this.runtimes.has(sessionId)) this.turnIndexCache.set(sessionId, entries);
13143
+ }
13144
+ dbgMs(
13145
+ "session._rebuildTurnIndex",
13146
+ __t0,
13147
+ // [dbg]
13148
+ `sid=${sessionId.slice(0, 8)} events=${events.length} turns=${entries.length}`
13149
+ );
13150
+ return entries;
13151
+ }
12557
13152
  /**
12558
13153
  * 按字节预算截取事件「尾部」(至少保留最后一条),确保 MQTT 回执远小于单包上限。
12559
13154
  * 供 get_events 使用:会话历史可任意大,但 MQTT 链路不能承载大包。
12560
13155
  */
12561
- _boundedTail(events) {
13156
+ _boundedTail(events, maxBytes = EVENTS_ACK_MAX_BYTES) {
12562
13157
  const __t0 = dbgNow();
12563
13158
  let total = 0;
12564
13159
  let i = events.length;
12565
13160
  while (i > 0) {
12566
13161
  const size = Buffer.byteLength(JSON.stringify(events[i - 1]));
12567
- if (total > 0 && total + size > EVENTS_ACK_MAX_BYTES) break;
13162
+ if (total > 0 && total + size > maxBytes) break;
12568
13163
  total += size;
12569
13164
  i -= 1;
12570
13165
  }
@@ -12684,6 +13279,7 @@ data: ${JSON.stringify(entry.data)}
12684
13279
  nameSource: rec.nameSource,
12685
13280
  taskCount: rec.taskCount ?? 0,
12686
13281
  lastCompletedAt: rec.lastCompletedAt,
13282
+ lastStartedAt: rec.lastStartedAt,
12687
13283
  naming: false,
12688
13284
  turnStartedAt: 0
12689
13285
  };
@@ -12701,10 +13297,11 @@ data: ${JSON.stringify(entry.data)}
12701
13297
  }
12702
13298
  /** 装配引擎并启动一轮 Turn(供 submitTurn / submitOrSteer 复用) */
12703
13299
  _startTurn(rt, input) {
13300
+ const engine = this._buildEngine(rt.tenant, rt, true);
12704
13301
  rt.lastInput = input;
12705
- this._setStatus(rt, "running");
12706
13302
  rt.turnStartedAt = Date.now();
12707
- const engine = this._buildEngine(rt.tenant, rt, true);
13303
+ rt.lastStartedAt = new Date(rt.turnStartedAt).toISOString();
13304
+ this._setStatus(rt, "running");
12708
13305
  rt.engine = engine;
12709
13306
  this._persist(rt);
12710
13307
  rt.runTask = this._drive(rt, engine.run(input));
@@ -12763,18 +13360,18 @@ data: ${JSON.stringify(entry.data)}
12763
13360
  this._setStatus(rt, "completed");
12764
13361
  rt.taskCount += 1;
12765
13362
  rt.lastCompletedAt = (/* @__PURE__ */ new Date()).toISOString();
12766
- this._emitNotify(rt, "turn_completed", {
12767
- ...event.data,
12768
- summary,
12769
- durationMs
12770
- });
13363
+ this._emitNotify(
13364
+ rt,
13365
+ "turn_completed",
13366
+ this._turnEndData(rt, event.data, { summary, durationMs })
13367
+ );
12771
13368
  } else if (status === "max_iterations_exceeded") {
12772
13369
  this._setStatus(rt, "stopped");
12773
- this._emitNotify(rt, "turn_stopped", {
12774
- ...event.data,
12775
- summary,
12776
- durationMs
12777
- });
13370
+ this._emitNotify(
13371
+ rt,
13372
+ "turn_stopped",
13373
+ this._turnEndData(rt, event.data, { summary, durationMs })
13374
+ );
12778
13375
  }
12779
13376
  this._persist(rt);
12780
13377
  if (status === "completed" && engine && shouldAutoName(rt.taskCount, rt.nameSource)) {
@@ -12785,7 +13382,7 @@ data: ${JSON.stringify(entry.data)}
12785
13382
  case "error" /* ERROR */:
12786
13383
  this._setStatus(rt, "error");
12787
13384
  this._persist(rt);
12788
- this._emitNotify(rt, "error", event.data);
13385
+ this._emitNotify(rt, "error", this._turnEndData(rt, event.data, {}));
12789
13386
  break;
12790
13387
  case "interrupted" /* INTERRUPTED */:
12791
13388
  this._setStatus(rt, "interrupted");
@@ -12811,9 +13408,17 @@ data: ${JSON.stringify(entry.data)}
12811
13408
  this._setStatus(rt, "error");
12812
13409
  this._persist(rt);
12813
13410
  } finally {
13411
+ if (rt.turnIndex) {
13412
+ const turn = rt.turnIndex;
13413
+ if (!turn.endedAt && turn.error) turn.status = "error";
13414
+ this._appendTurnIndex(rt, turn);
13415
+ rt.turnIndex = void 0;
13416
+ rt.turnToolKeys = void 0;
13417
+ }
12814
13418
  rt.runTask = null;
12815
13419
  rt.engine = null;
12816
13420
  rt.pendingSteers = [];
13421
+ rt.steerBaseInput = void 0;
12817
13422
  }
12818
13423
  }
12819
13424
  _buffer(rt, event) {
@@ -12826,6 +13431,7 @@ data: ${JSON.stringify(entry.data)}
12826
13431
  rt.events.push(entry);
12827
13432
  if (rt.events.length > EVENT_BUFFER_MAX) rt.events.shift();
12828
13433
  void this.store.appendEvent(rt.tenantId, rt.sessionId, entry);
13434
+ this._trackTurnIndex(rt, entry);
12829
13435
  for (const res of rt.subscribers) this._sseSend(res, entry);
12830
13436
  this._broadcastSessionEvent(rt.tenantId, rt.sessionId, entry);
12831
13437
  try {
@@ -12833,6 +13439,42 @@ data: ${JSON.stringify(entry.data)}
12833
13439
  } catch {
12834
13440
  }
12835
13441
  }
13442
+ /**
13443
+ * 维护运行中轮次的索引(in-memory 记账 + 关键节点落盘)。
13444
+ *
13445
+ * 落盘只在「开轮 / 补充消息 / 收轮」发生,delta 事件仅推进 endSeq 与统计,
13446
+ * 因此索引写入量是每轮 2~3 行,与会话事件量无关。
13447
+ */
13448
+ _trackTurnIndex(rt, ev) {
13449
+ if (ev.type === "turn_start") {
13450
+ const turn2 = newTurnEntry(ev, String(ev.data?.["input"] ?? ""));
13451
+ rt.turnIndex = turn2;
13452
+ rt.turnToolKeys = /* @__PURE__ */ new Set();
13453
+ this._appendTurnIndex(rt, turn2);
13454
+ return;
13455
+ }
13456
+ const turn = rt.turnIndex;
13457
+ if (!turn) return;
13458
+ if (ev.type === "turn_end" || ev.type === "interrupted") {
13459
+ closeTurnEntry(turn, ev);
13460
+ this._appendTurnIndex(rt, turn);
13461
+ rt.turnIndex = void 0;
13462
+ rt.turnToolKeys = void 0;
13463
+ return;
13464
+ }
13465
+ if (!rt.turnToolKeys) rt.turnToolKeys = /* @__PURE__ */ new Set();
13466
+ const inputsBefore = turn.inputs.length;
13467
+ applyTurnEvent(turn, ev, rt.turnToolKeys);
13468
+ if (turn.inputs.length !== inputsBefore) this._appendTurnIndex(rt, turn);
13469
+ }
13470
+ /** 写入一版轮次索引(内存副本 + 异步落盘) */
13471
+ _appendTurnIndex(rt, turn) {
13472
+ const cached3 = this.turnIndexCache.get(rt.sessionId) ?? [];
13473
+ const i = cached3.findIndex((t) => t.startSeq === turn.startSeq);
13474
+ const next = i >= 0 ? [...cached3.slice(0, i), turn, ...cached3.slice(i + 1)] : [...cached3, turn];
13475
+ this.turnIndexCache.set(rt.sessionId, next);
13476
+ void this.store.appendTurnIndex(rt.tenantId, rt.sessionId, turn);
13477
+ }
12836
13478
  /** 回收内存 run 任务(释放引用交给 GC;磁盘状态保持不动) */
12837
13479
  _recycleRunTask(rt) {
12838
13480
  rt.runTask = null;
@@ -12870,6 +13512,7 @@ data: ${JSON.stringify(entry.data)}
12870
13512
  nameSource: rt.nameSource,
12871
13513
  taskCount: rt.taskCount,
12872
13514
  lastCompletedAt: rt.lastCompletedAt,
13515
+ lastStartedAt: rt.lastStartedAt,
12873
13516
  modelOverridden: rt.modelOverridden,
12874
13517
  providerId: rt.providerId,
12875
13518
  permissionMode: rt.permissionMode,
@@ -13273,6 +13916,7 @@ function allSessionStates(store, manager) {
13273
13916
  name: s.name ?? null,
13274
13917
  status: s.status,
13275
13918
  lastUserMessage: lastUserMessage(s),
13919
+ lastStartedAt: s.lastStartedAt ?? null,
13276
13920
  lastCompletedAt: lastCompletedAt(s)
13277
13921
  });
13278
13922
  }
@@ -13327,7 +13971,7 @@ function buildNodeInfo(store, opts) {
13327
13971
  nodeId: opts.nodeId ?? ensureNodeId(store),
13328
13972
  platform: process.platform,
13329
13973
  mode: process.env.NODE_MODE ?? "standalone",
13330
- version: "0.2.14",
13974
+ version: "0.2.16",
13331
13975
  admin: opts.admin,
13332
13976
  projects: opts.projects
13333
13977
  };
@@ -13449,7 +14093,7 @@ async function maybeAccountEnroll(opts = {}) {
13449
14093
  cliErr(`\u63A5\u5165\u68C0\u67E5\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`);
13450
14094
  return;
13451
14095
  }
13452
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
14096
+ const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout });
13453
14097
  try {
13454
14098
  await runWizard(rl2, store, opts);
13455
14099
  } catch (e) {
@@ -13543,7 +14187,7 @@ var path25 = __toESM(require("path"), 1);
13543
14187
  var import_node_child_process4 = require("child_process");
13544
14188
  var import_node_module3 = require("module");
13545
14189
  function currentVersion() {
13546
- const injected = "0.2.14"?.trim();
14190
+ const injected = "0.2.16"?.trim();
13547
14191
  if (injected) return injected;
13548
14192
  try {
13549
14193
  const req2 = (0, import_node_module3.createRequire)(importMetaUrl);
@@ -14259,7 +14903,7 @@ async function runLinkCommand(argv, ctx = {}) {
14259
14903
  const getRl = () => {
14260
14904
  if (ctx.rl) return asPrompter(ctx.rl);
14261
14905
  if (!interactive) return null;
14262
- ownedRl = readline2.createInterface({ input: process.stdin, output: process.stdout });
14906
+ ownedRl = readline3.createInterface({ input: process.stdin, output: process.stdout });
14263
14907
  return asPrompter(ownedRl);
14264
14908
  };
14265
14909
  const closeOwnedRl = () => {
@@ -14881,6 +15525,7 @@ var fs23 = __toESM(require("fs"), 1);
14881
15525
  var path30 = __toESM(require("path"), 1);
14882
15526
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
14883
15527
  var MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024;
15528
+ var MAX_BINARY_BYTES = 2.5 * 1024 * 1024;
14884
15529
  var MAX_ENTRIES2 = 2e3;
14885
15530
  function toPosix(p) {
14886
15531
  return p.split(path30.sep).join("/");
@@ -15028,6 +15673,26 @@ function writeProjectFile(workspaceRoot, relPath, content) {
15028
15673
  }
15029
15674
  return { path: toPosix(path30.relative(base, file)), size: buf.length };
15030
15675
  }
15676
+ function writeProjectFileBase64(workspaceRoot, relPath, base64) {
15677
+ const base = projectRoot(workspaceRoot);
15678
+ const rel = (relPath ?? "").trim();
15679
+ if (!rel) throw new Error("\u6587\u4EF6\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A");
15680
+ const file = resolveWithin(base, rel);
15681
+ const b64 = String(base64 ?? "").replace(/\s+/g, "");
15682
+ if (!b64) throw new Error("\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A");
15683
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) throw new Error("\u5185\u5BB9\u4E0D\u662F\u5408\u6CD5\u7684 base64");
15684
+ if (Math.floor(b64.length * 3 / 4) > MAX_BINARY_BYTES) {
15685
+ throw new Error(`\u5185\u5BB9\u8D85\u8FC7 ${MAX_BINARY_BYTES / (1024 * 1024)}MB \u4E0A\u9650\uFF0C\u65E0\u6CD5\u4FDD\u5B58`);
15686
+ }
15687
+ const buf = Buffer.from(b64, "base64");
15688
+ try {
15689
+ fs23.mkdirSync(path30.dirname(file), { recursive: true });
15690
+ fs23.writeFileSync(file, buf);
15691
+ } catch (e) {
15692
+ throw new Error(`\u5199\u5165\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`);
15693
+ }
15694
+ return { path: toPosix(path30.relative(base, file)), size: buf.length };
15695
+ }
15031
15696
  function createProjectDir(workspaceRoot, relPath) {
15032
15697
  const base = projectRoot(workspaceRoot);
15033
15698
  const rel = (relPath ?? "").trim();
@@ -15101,6 +15766,42 @@ function searchProjectFiles(workspaceRoot, query) {
15101
15766
  walk(base, "");
15102
15767
  return out;
15103
15768
  }
15769
+ function deleteProjectPaths(workspaceRoot, relPaths) {
15770
+ const base = projectRoot(workspaceRoot);
15771
+ const out = { deleted: [], failed: [] };
15772
+ const seen = /* @__PURE__ */ new Set();
15773
+ for (const raw of relPaths ?? []) {
15774
+ const rel = (raw ?? "").trim().replace(/^[/\\]+/, "");
15775
+ if (!rel) {
15776
+ out.failed.push({ path: String(raw ?? ""), error: "\u4E0D\u80FD\u5220\u9664\u9879\u76EE\u6839\u76EE\u5F55" });
15777
+ continue;
15778
+ }
15779
+ if (seen.has(rel)) continue;
15780
+ seen.add(rel);
15781
+ let abs;
15782
+ try {
15783
+ abs = resolveWithin(base, rel);
15784
+ } catch (e) {
15785
+ out.failed.push({ path: rel, error: e instanceof Error ? e.message : String(e) });
15786
+ continue;
15787
+ }
15788
+ if (path30.resolve(abs) === path30.resolve(base)) {
15789
+ out.failed.push({ path: rel, error: "\u4E0D\u80FD\u5220\u9664\u9879\u76EE\u6839\u76EE\u5F55" });
15790
+ continue;
15791
+ }
15792
+ if (!fs23.existsSync(abs)) {
15793
+ out.failed.push({ path: rel, error: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
15794
+ continue;
15795
+ }
15796
+ try {
15797
+ fs23.rmSync(abs, { recursive: true, force: true });
15798
+ out.deleted.push(toPosix(path30.relative(base, abs)));
15799
+ } catch (e) {
15800
+ out.failed.push({ path: rel, error: e instanceof Error ? e.message : String(e) });
15801
+ }
15802
+ }
15803
+ return out;
15804
+ }
15104
15805
 
15105
15806
  // src/server/projectOps.ts
15106
15807
  init_cjs_shims();
@@ -15506,7 +16207,7 @@ function buildNodeHost(store, manager, diskPath) {
15506
16207
  arch: process.arch,
15507
16208
  release: os9.release(),
15508
16209
  uptimeSec: Math.round(os9.uptime()),
15509
- version: "0.2.14",
16210
+ version: "0.2.16",
15510
16211
  mode: process.env.NODE_MODE ?? "standalone",
15511
16212
  cpu: {
15512
16213
  count: cpus2.length,
@@ -15649,6 +16350,7 @@ function parseApprovalDecision(raw) {
15649
16350
  }
15650
16351
  function createApp(store, manager, opts = {}) {
15651
16352
  setupServerHandlers(manager);
16353
+ const updater = opts.updater ?? null;
15652
16354
  const routes = [
15653
16355
  // ---------- 会话管理 ----------
15654
16356
  {
@@ -15790,6 +16492,18 @@ function createApp(store, manager, opts = {}) {
15790
16492
  ok(res, { accepted: true });
15791
16493
  }
15792
16494
  },
16495
+ {
16496
+ method: "POST",
16497
+ pattern: ["sessions", ":id", "steer", "remove"],
16498
+ // 撤销未注入的补充信息
16499
+ fn: (_req, res, { tenant, params, body }) => {
16500
+ const index = Number(body?.index);
16501
+ if (!Number.isInteger(index))
16502
+ throw new ApiError(400, "bad_request", "index \u5FC5\u987B\u662F\u6574\u6570");
16503
+ const pendingSteers = manager.unsteer(tenant, params["id"], index);
16504
+ ok(res, { accepted: true, pendingSteers });
16505
+ }
16506
+ },
15793
16507
  {
15794
16508
  method: "POST",
15795
16509
  pattern: ["sessions", ":id", "interrupt"],
@@ -16320,6 +17034,30 @@ function createApp(store, manager, opts = {}) {
16320
17034
  }
16321
17035
  }
16322
17036
  },
17037
+ {
17038
+ // 二进制写入(图片编辑保存):body = { path, base64 }
17039
+ method: "POST",
17040
+ pattern: ["files", "binary"],
17041
+ fn: (_req, res, { tenant, body }) => {
17042
+ const raw = body ?? {};
17043
+ try {
17044
+ ok(
17045
+ res,
17046
+ writeProjectFileBase64(
17047
+ tenant.workspaceRoot ?? null,
17048
+ String(raw.path ?? ""),
17049
+ String(raw.base64 ?? "")
17050
+ )
17051
+ );
17052
+ } catch (e) {
17053
+ throw new ApiError(
17054
+ 400,
17055
+ "bad_request",
17056
+ e instanceof Error ? e.message : String(e)
17057
+ );
17058
+ }
17059
+ }
17060
+ },
16323
17061
  // ---------- 项目终端(在工作区执行命令) ----------
16324
17062
  {
16325
17063
  method: "POST",
@@ -16670,6 +17408,21 @@ function createApp(store, manager, opts = {}) {
16670
17408
  }
16671
17409
  }
16672
17410
  },
17411
+ {
17412
+ method: "POST",
17413
+ pattern: ["files", "delete"],
17414
+ // 删除文件 / 目录(目录递归);逐条返回失败原因
17415
+ fn: (_req, res, { tenant, body }) => {
17416
+ const raw = body ?? {};
17417
+ const paths = Array.isArray(raw.paths) ? raw.paths.map((x) => String(x)) : [];
17418
+ if (paths.length === 0) throw new ApiError(400, "bad_request", "paths \u4E0D\u80FD\u4E3A\u7A7A");
17419
+ try {
17420
+ ok(res, deleteProjectPaths(tenant.workspaceRoot ?? null, paths));
17421
+ } catch (e) {
17422
+ throw new ApiError(400, "bad_request", e instanceof Error ? e.message : String(e));
17423
+ }
17424
+ }
17425
+ },
16673
17426
  {
16674
17427
  method: "POST",
16675
17428
  pattern: ["files", "create"],
@@ -16898,6 +17651,34 @@ function createApp(store, manager, opts = {}) {
16898
17651
  }
16899
17652
  }
16900
17653
  },
17654
+ // ---------- 节点自更新(状态 / 检查 / 安装) ----------
17655
+ // action=install 先回执、后台安装并退出进程(由服务管理器拉起新版本),
17656
+ // 因此回执成功只代表「已受理」,是否升级成功要用 action=status 再确认。
17657
+ {
17658
+ method: "POST",
17659
+ pattern: ["node", "self-update"],
17660
+ projectOptional: true,
17661
+ fn: async (req2, res, { body }) => {
17662
+ const b = body ?? {};
17663
+ const action = String(b.action ?? "status");
17664
+ if (!updater) {
17665
+ throw new ApiError(400, "bad_request", "\u81EA\u66F4\u65B0\u4E0D\u53EF\u7528\uFF1A\u8282\u70B9\u672A\u4EE5 serve \u6A21\u5F0F\u8FD0\u884C");
17666
+ }
17667
+ if (action === "status") {
17668
+ ok(res, updater.status());
17669
+ return;
17670
+ }
17671
+ if (action === "check") {
17672
+ ok(res, await updater.inspect());
17673
+ return;
17674
+ }
17675
+ if (action === "install") {
17676
+ ok(res, await updater.requestInstall(b.force === true));
17677
+ return;
17678
+ }
17679
+ throw new ApiError(400, "bad_request", `\u672A\u77E5\u7684 action\uFF1A${action}\uFF08\u53EF\u9009 status / check / install\uFF09`);
17680
+ }
17681
+ },
16901
17682
  // ---------- 节点级会话状态(跨项目;只含标识与 status) ----------
16902
17683
  // 供节点首页 / 工作台建立初始视图,后续变化由 session_status 通知增量推送。
16903
17684
  {
@@ -17060,7 +17841,7 @@ function createApp(store, manager, opts = {}) {
17060
17841
  // 阻止反向代理(nginx 等)缓冲 SSE 流
17061
17842
  "X-Accel-Buffering": "no"
17062
17843
  });
17063
- const sinceRaw = new URL(
17844
+ const sinceRaw2 = new URL(
17064
17845
  req2.url,
17065
17846
  "http://localhost"
17066
17847
  ).searchParams.get("since");
@@ -17068,19 +17849,51 @@ function createApp(store, manager, opts = {}) {
17068
17849
  tenant.id,
17069
17850
  params["id"],
17070
17851
  res,
17071
- sinceRaw ? Number(sinceRaw) : 0
17852
+ sinceRaw2 ? Number(sinceRaw2) : 0
17072
17853
  );
17073
17854
  return;
17074
17855
  }
17075
- const since = Number(
17076
- new URL(req2.url, "http://localhost").searchParams.get("since") ?? 0
17856
+ const q = new URL(req2.url, "http://localhost").searchParams;
17857
+ const sinceRaw = q.get("since");
17858
+ const beforeRaw = q.get("before");
17859
+ const maxBytesRaw = q.get("maxBytes");
17860
+ ok(
17861
+ res,
17862
+ manager.eventsPage(tenant.id, params["id"], {
17863
+ since: sinceRaw === null ? void 0 : Number(sinceRaw) || 0,
17864
+ before: beforeRaw === null ? void 0 : Number(beforeRaw) || 0,
17865
+ maxBytes: maxBytesRaw === null ? void 0 : Number(maxBytesRaw) || void 0
17866
+ })
17077
17867
  );
17078
- const { events, latestSeq } = manager.eventsSince(
17079
- tenant.id,
17080
- params["id"],
17081
- since
17868
+ }
17869
+ },
17870
+ {
17871
+ method: "GET",
17872
+ pattern: ["sessions", ":id", "turns"],
17873
+ // 轮次汇总(会话详情的列表)
17874
+ fn: async (req2, res, { tenant, params }) => {
17875
+ const q = new URL(req2.url, "http://localhost").searchParams;
17876
+ const beforeRaw = q.get("before");
17877
+ const limitRaw = q.get("limit");
17878
+ ok(
17879
+ res,
17880
+ await manager.sessionTurns(tenant.id, params["id"], {
17881
+ before: beforeRaw === null ? void 0 : Number(beforeRaw) || 0,
17882
+ limit: limitRaw === null ? void 0 : Number(limitRaw) || void 0
17883
+ })
17082
17884
  );
17083
- ok(res, { events, latestSeq });
17885
+ }
17886
+ },
17887
+ {
17888
+ method: "GET",
17889
+ // 单轮详情:用户消息(含补充) + 最终回复 + 产出文件
17890
+ pattern: ["sessions", ":id", "turns", ":seq", "reply"],
17891
+ fn: async (req2, res, { tenant, params }) => {
17892
+ const startSeq = Number(params["seq"]);
17893
+ if (!Number.isFinite(startSeq) || startSeq <= 0) {
17894
+ throw new ApiError(400, "bad_request", "\u65E0\u6548\u7684\u8F6E\u6B21\u5E8F\u53F7");
17895
+ }
17896
+ ok(res, await manager.sessionTurnReply(tenant.id, params["id"], startSeq));
17084
17897
  }
17085
17898
  }
17086
17899
  ];
@@ -17113,7 +17926,7 @@ function createApp(store, manager, opts = {}) {
17113
17926
  json(res, 200, {
17114
17927
  status: "ok",
17115
17928
  // 版本让运维/探针能确认线上跑的是哪个构建(更新与回滚据此判断)
17116
- version: "0.2.14",
17929
+ version: "0.2.16",
17117
17930
  features: { mcp: hasServerMcpConfig() }
17118
17931
  });
17119
17932
  return;
@@ -17386,6 +18199,24 @@ async function dispatchFleetCommand(deps, env3) {
17386
18199
  switch (env3.type) {
17387
18200
  case "ping":
17388
18201
  return ok2({ pong: true, ts: Date.now() });
18202
+ case "self_update": {
18203
+ const updater = deps.getUpdater?.() ?? null;
18204
+ if (!updater) {
18205
+ return fail("\u81EA\u66F4\u65B0\u4E0D\u53EF\u7528\uFF1A\u8282\u70B9\u672A\u4EE5 serve \u6A21\u5F0F\u8FD0\u884C\uFF08\u7F3A\u5C11\u66F4\u65B0\u63A7\u5236\u5668\uFF09");
18206
+ }
18207
+ const action = String(payload.action ?? "status");
18208
+ if (action === "status") {
18209
+ return ok2(updater.status());
18210
+ }
18211
+ if (action === "check") {
18212
+ return ok2(await updater.inspect());
18213
+ }
18214
+ if (action === "install") {
18215
+ const started = await updater.requestInstall(payload.force === true);
18216
+ return ok2(started);
18217
+ }
18218
+ return fail(`\u672A\u77E5\u7684 action\uFF1A${action}\uFF08\u53EF\u9009 status / check / install\uFF09`);
18219
+ }
17389
18220
  case "get_node_info":
17390
18221
  return ok2(
17391
18222
  buildNodeInfo(store, {
@@ -17508,8 +18339,22 @@ async function dispatchFleetCommand(deps, env3) {
17508
18339
  );
17509
18340
  }
17510
18341
  case "get_events": {
17511
- const since = Number(payload.since ?? 0) || 0;
17512
- return ok2(manager.eventsSince(project().id, sessionId(), since));
18342
+ const since = payload.since === void 0 ? void 0 : Number(payload.since) || 0;
18343
+ const before = payload.before === void 0 ? void 0 : Number(payload.before) || 0;
18344
+ const maxBytes = Number(payload.maxBytes) || void 0;
18345
+ return ok2(
18346
+ manager.eventsPage(project().id, sessionId(), { since, before, maxBytes })
18347
+ );
18348
+ }
18349
+ case "get_session_turns": {
18350
+ const before = payload.before === void 0 ? void 0 : Number(payload.before) || 0;
18351
+ const limit = Number(payload.limit) || void 0;
18352
+ return ok2(await manager.sessionTurns(project().id, sessionId(), { before, limit }));
18353
+ }
18354
+ case "get_session_turn_reply": {
18355
+ const startSeq = Number(payload.startSeq) || 0;
18356
+ if (startSeq <= 0) throw new ApiError(400, "bad_request", "\u65E0\u6548\u7684\u8F6E\u6B21\u5E8F\u53F7");
18357
+ return ok2(await manager.sessionTurnReply(project().id, sessionId(), startSeq));
17513
18358
  }
17514
18359
  case "get_config":
17515
18360
  return ok2(configView2(project()));
@@ -17570,6 +18415,16 @@ async function dispatchFleetCommand(deps, env3) {
17570
18415
  )
17571
18416
  );
17572
18417
  }
18418
+ case "write_file_base64": {
18419
+ const t = project();
18420
+ return ok2(
18421
+ writeProjectFileBase64(
18422
+ t.workspaceRoot ?? null,
18423
+ String(payload.path ?? ""),
18424
+ String(payload.base64 ?? "")
18425
+ )
18426
+ );
18427
+ }
17573
18428
  case "create_dir": {
17574
18429
  const t = project();
17575
18430
  return ok2(createProjectDir(t.workspaceRoot ?? null, String(payload.path ?? "")));
@@ -17584,6 +18439,13 @@ async function dispatchFleetCommand(deps, env3) {
17584
18439
  )
17585
18440
  );
17586
18441
  }
18442
+ case "delete_files": {
18443
+ const t = project();
18444
+ const raw = payload.paths;
18445
+ const paths = Array.isArray(raw) ? raw.map((x) => String(x)) : [];
18446
+ if (paths.length === 0) throw new ApiError(400, "bad_request", "paths \u4E0D\u80FD\u4E3A\u7A7A");
18447
+ return ok2(deleteProjectPaths(t.workspaceRoot ?? null, paths));
18448
+ }
17587
18449
  case "search_files": {
17588
18450
  const t = project();
17589
18451
  return ok2(searchProjectFiles(t.workspaceRoot ?? null, String(payload.query ?? "")));
@@ -17810,6 +18672,13 @@ async function dispatchFleetCommand(deps, env3) {
17810
18672
  manager.steer(project(), sessionId(), text);
17811
18673
  return ok2({ accepted: true });
17812
18674
  }
18675
+ case "unsteer": {
18676
+ const index = Number(payload.index);
18677
+ if (!Number.isInteger(index)) {
18678
+ throw new ApiError(400, "bad_request", "index \u5FC5\u987B\u662F\u6574\u6570");
18679
+ }
18680
+ return ok2({ accepted: true, pendingSteers: manager.unsteer(project(), sessionId(), index) });
18681
+ }
17813
18682
  case "interrupt":
17814
18683
  manager.interrupt(project(), sessionId());
17815
18684
  return ok2({ accepted: true });
@@ -18345,6 +19214,8 @@ var READONLY_CMD_TYPES = /* @__PURE__ */ new Set([
18345
19214
  "get_session_states",
18346
19215
  "get_state",
18347
19216
  "get_events",
19217
+ "get_session_turns",
19218
+ "get_session_turn_reply",
18348
19219
  "get_config",
18349
19220
  "list_models",
18350
19221
  "list_dirs",
@@ -18594,6 +19465,10 @@ var FleetNodeBridge = class {
18594
19465
  _dispatchEnvelope(env3) {
18595
19466
  const run = () => this._dispatch(env3);
18596
19467
  dbgMark(`recv:${env3.type}`, `reqId=${env3.reqId}`);
19468
+ if (env3.type === "self_update" && String(env3.payload?.action ?? "status") !== "install") {
19469
+ this._runReadonly(run);
19470
+ return;
19471
+ }
18597
19472
  if (READONLY_CMD_TYPES.has(env3.type) || this._isReadonlyBatch(env3)) {
18598
19473
  this._runReadonly(run);
18599
19474
  return;
@@ -18620,7 +19495,7 @@ var FleetNodeBridge = class {
18620
19495
  _invalidateSessionCache() {
18621
19496
  this.writeEpoch += 1;
18622
19497
  for (const key of [...this.cmdResultCache.keys()]) {
18623
- if (/^(get_state|get_events|list_sessions|get_session_states)\|/.test(key)) {
19498
+ if (/^(get_state|get_events|get_session_turns|get_session_turn_reply|list_sessions|get_session_states)\|/.test(key)) {
18624
19499
  this.cmdResultCache.delete(key);
18625
19500
  }
18626
19501
  }
@@ -18756,11 +19631,17 @@ var FleetNodeBridge = class {
18756
19631
  label: this.opts.deps.store.loadNodeLabel() ?? void 0,
18757
19632
  platform: process.platform,
18758
19633
  mode: process.env.NODE_MODE ?? "standalone",
18759
- version: "0.2.14",
19634
+ version: "0.2.16",
18760
19635
  ip: primaryIp()
18761
19636
  };
18762
19637
  }
18763
- /** 用量汇总(供控制面账户级聚合):总量 + 最近 40 天按天 */
19638
+ /**
19639
+ * 用量汇总(供控制面账户级聚合):总量 + 最近 40 天按天 + 最近 40 天按项目。
19640
+ *
19641
+ * 按项目这一维是为控制台「今日 / 最近 30 天消耗拆到每个项目」服务的:
19642
+ * 账本里的汇总只存了按天、按项目两个独立维度,交叉项只能现算,因此这里
19643
+ * 从明细聚合后随 info 一起上报(Hub 按 (节点, 项目, 天) 覆盖写入,天然幂等)。
19644
+ */
18764
19645
  _usagePayload() {
18765
19646
  const ledger = this.opts.deps.manager.usageLedger();
18766
19647
  if (!ledger) return void 0;
@@ -18768,11 +19649,22 @@ var FleetNodeBridge = class {
18768
19649
  const days = {};
18769
19650
  const keys = Object.keys(r.byDay).sort().slice(-40);
18770
19651
  for (const k of keys) days[k] = r.byDay[k];
19652
+ const from = keys[0] ?? "";
19653
+ const to = keys[keys.length - 1] ?? "";
19654
+ const projects = {};
19655
+ if (from && to) {
19656
+ for (const [projectId, byDay] of Object.entries(
19657
+ ledger.projectByDay({ from, to })
19658
+ )) {
19659
+ projects[projectId] = { total: sumAgg(Object.values(byDay)), byDay };
19660
+ }
19661
+ }
18771
19662
  return {
18772
19663
  since: r.since,
18773
19664
  total: r.total,
18774
19665
  legacy: r.legacy ?? null,
18775
- byDay: days
19666
+ byDay: days,
19667
+ projects
18776
19668
  };
18777
19669
  }
18778
19670
  /**
@@ -18807,6 +19699,8 @@ var FleetNodeBridge = class {
18807
19699
  name: s.name ?? null,
18808
19700
  status: s.status,
18809
19701
  lastUserMessage: lastUserMessage(s),
19702
+ // 控制面按它排「最近在跑什么」,运行中的长任务才不会被排到后面
19703
+ lastStartedAt: s.lastStartedAt ?? null,
18810
19704
  // 控制面的聚合邮件靠它判断「这台机器最近有没有在干活」;
18811
19705
  // 状态跃迁可能被 info 节流跳过,完成时间不会。
18812
19706
  lastCompletedAt: s.lastCompletedAt ?? null
@@ -18835,7 +19729,7 @@ var FleetNodeBridge = class {
18835
19729
  label: this.opts.deps.store.loadNodeLabel() ?? void 0,
18836
19730
  platform: process.platform,
18837
19731
  mode: process.env.NODE_MODE ?? "standalone",
18838
- version: "0.2.14",
19732
+ version: "0.2.16",
18839
19733
  ip: primaryIp(),
18840
19734
  projects,
18841
19735
  totals,
@@ -20376,7 +21270,7 @@ function verifySignature(data, signatureBase64, publicKey) {
20376
21270
  }
20377
21271
 
20378
21272
  // src/update/downloader.ts
20379
- var MAX_BINARY_BYTES = 400 * 1024 * 1024;
21273
+ var MAX_BINARY_BYTES2 = 400 * 1024 * 1024;
20380
21274
  var MANIFEST_TIMEOUT_MS = 15e3;
20381
21275
  var DOWNLOAD_TIMEOUT_MS2 = 10 * 6e4;
20382
21276
  async function fetchManifest(url, publicKey, opts = {}) {
@@ -20422,7 +21316,7 @@ async function downloadBinary(bin, destPath, log = () => {
20422
21316
  if (!res.ok) return { error: `\u4E0B\u8F7D\u5931\u8D25 HTTP ${res.status}` };
20423
21317
  if (!res.body) return { error: "\u4E0B\u8F7D\u54CD\u5E94\u6CA1\u6709 body" };
20424
21318
  const expected = bin.size && bin.size > 0 ? bin.size : void 0;
20425
- const limit = expected ? Math.max(expected * 2, 1024 * 1024) : MAX_BINARY_BYTES;
21319
+ const limit = expected ? Math.max(expected * 2, 1024 * 1024) : MAX_BINARY_BYTES2;
20426
21320
  const hash = (0, import_node_crypto5.createHash)("sha256");
20427
21321
  let bytes = 0;
20428
21322
  try {
@@ -20629,6 +21523,31 @@ function nextIntervalMs() {
20629
21523
  const jitter = base * 0.15 * (Math.random() * 2 - 1);
20630
21524
  return Math.max(6e4, Math.round(base + jitter));
20631
21525
  }
21526
+ function evaluateManifest(base, manifest) {
21527
+ if (!parseVersion(base.currentVersion) || compareVersions(manifest.version, base.currentVersion) <= 0) {
21528
+ return {
21529
+ ...base,
21530
+ updateAvailable: false,
21531
+ latestVersion: manifest.version,
21532
+ reason: `\u5DF2\u662F\u6700\u65B0\u7248\u672C\uFF08v${base.currentVersion}\uFF09`
21533
+ };
21534
+ }
21535
+ if (manifest.minimumVersion && compareVersions(base.currentVersion, manifest.minimumVersion) < 0) {
21536
+ return {
21537
+ ...base,
21538
+ updateAvailable: false,
21539
+ latestVersion: manifest.version,
21540
+ manualUpgradeRequired: manifest.minimumVersion,
21541
+ reason: `\u5F53\u524D\u7248\u672C\u8FC7\u4F4E\uFF0C\u9700\u624B\u52A8\u5347\u7EA7\u5230 >= ${manifest.minimumVersion}`
21542
+ };
21543
+ }
21544
+ return {
21545
+ ...base,
21546
+ updateAvailable: true,
21547
+ latestVersion: manifest.version,
21548
+ ...manifest.notes ? { notes: manifest.notes } : {}
21549
+ };
21550
+ }
20632
21551
  var UpdateController = class {
20633
21552
  constructor(opts, currentVersion2 = currentVersion()) {
20634
21553
  this.opts = opts;
@@ -20717,6 +21636,77 @@ var UpdateController = class {
20717
21636
  }
20718
21637
  this.schedule(nextIntervalMs());
20719
21638
  }
21639
+ /**
21640
+ * 只读检查:拉清单比对版本,不下载、不改 state。
21641
+ *
21642
+ * 供远端 `self_update --action check` 与 UI 展示用;与定时器 `check()` 不同,
21643
+ * 这里不加 `checking` 锁、不写 `lastCheckAt`,因此不会干扰自动更新流程。
21644
+ */
21645
+ async inspect() {
21646
+ const base = {
21647
+ currentVersion: this.currentVersion,
21648
+ channel: updaterChannel(),
21649
+ manifestUrl: this.manifestUrl()
21650
+ };
21651
+ const key = this.publicKey;
21652
+ const cap = selfUpdateCapability(Boolean(key));
21653
+ if (!cap.ok) {
21654
+ return { ...base, updateAvailable: false, reason: `\u81EA\u66F4\u65B0\u4E0D\u53EF\u7528\uFF1A${cap.reason}` };
21655
+ }
21656
+ const res = await fetchManifest(base.manifestUrl, key, {
21657
+ allowInsecureUrl: env("ZQAGENT_UPDATE_INSECURE") === "1"
21658
+ });
21659
+ if (!res.manifest) {
21660
+ return {
21661
+ ...base,
21662
+ updateAvailable: false,
21663
+ reason: `\u66F4\u65B0\u68C0\u67E5\u5931\u8D25\uFF1A${res.error ?? "\u6E05\u5355\u6821\u9A8C\u5931\u8D25"}`
21664
+ };
21665
+ }
21666
+ return evaluateManifest(base, res.manifest);
21667
+ }
21668
+ /**
21669
+ * 远端触发安装:先同步给出结论(受理/无需更新),安装本身在后台跑。
21670
+ *
21671
+ * 必须「先回执、后安装」:安装成功后进程会 `exit(70)` 交给 systemd 拉起新版本,
21672
+ * 如果等到安装完成再回执,回执永远发不出去(表现为网关超时)。
21673
+ *
21674
+ * 复用定时器同一条 `check()` 路径:验签、下载、自检、失败计数、安装后自检与
21675
+ * 回滚保护全都一致,只是把 `installMode` 设为 `force` 时不等待空闲。
21676
+ */
21677
+ async requestInstall(force = false) {
21678
+ const fromVersion = this.currentVersion;
21679
+ const cap = selfUpdateCapability(Boolean(this.publicKey));
21680
+ if (!cap.ok) {
21681
+ return { accepted: false, fromVersion, message: `\u81EA\u66F4\u65B0\u4E0D\u53EF\u7528\uFF1A${cap.reason}` };
21682
+ }
21683
+ const info = await this.inspect();
21684
+ if (!info.updateAvailable) {
21685
+ return {
21686
+ accepted: false,
21687
+ fromVersion,
21688
+ ...info.latestVersion ? { toVersion: info.latestVersion } : {},
21689
+ message: info.reason ?? "\u5DF2\u662F\u6700\u65B0\u7248\u672C"
21690
+ };
21691
+ }
21692
+ if (this.checking || this.installing) {
21693
+ return {
21694
+ accepted: true,
21695
+ fromVersion,
21696
+ toVersion: info.latestVersion,
21697
+ message: `\u5DF2\u6709\u66F4\u65B0\u4EFB\u52A1\u5728\u8FDB\u884C\u4E2D\uFF08\u76EE\u6807 v${info.latestVersion}\uFF09\uFF0C\u53EF\u5728\u7A0D\u540E\u7528 status \u67E5\u770B\u7ED3\u679C`
21698
+ };
21699
+ }
21700
+ void this.check(force, false, true).catch((err) => {
21701
+ this.recordFailure(info.latestVersion, `\u5B89\u88C5\u5931\u8D25\uFF1A${err.message}`);
21702
+ });
21703
+ return {
21704
+ accepted: true,
21705
+ fromVersion,
21706
+ toVersion: info.latestVersion,
21707
+ message: force ? `\u5F00\u59CB\u5B89\u88C5 v${info.latestVersion}\uFF08\u7ACB\u5373\u91CD\u542F\uFF09` : `\u5F00\u59CB\u5B89\u88C5 v${info.latestVersion}\uFF08\u7B49\u5F85\u8282\u70B9\u7A7A\u95F2\u540E\u91CD\u542F\uFF09`
21708
+ };
21709
+ }
20720
21710
  /**
20721
21711
  * 检查并准备更新;`installMode` 为 true 时下载后立即安装(不再等空闲)。
20722
21712
  * 返回可读结论,供 CLI `update --check` 使用。
@@ -21116,6 +22106,7 @@ function startServer(opts = {}) {
21116
22106
  const notificationHub = createNotificationHub();
21117
22107
  let fleet = null;
21118
22108
  let fleetCred = null;
22109
+ let updater = null;
21119
22110
  function connectFleet(cred) {
21120
22111
  const localId = ensureNodeId(store);
21121
22112
  const nodeId = (cred.nodeId ?? "").trim() || localId;
@@ -21133,6 +22124,8 @@ function startServer(opts = {}) {
21133
22124
  deps: {
21134
22125
  store,
21135
22126
  manager,
22127
+ // 闭包推迟求值:桥先于 UpdateController 创建,指令到达时 updater 已就绪
22128
+ getUpdater: () => updater,
21136
22129
  applyFleetCred: async (cred2) => {
21137
22130
  store.saveFleetCredential(cred2);
21138
22131
  setTimeout(() => connectFleet(cred2), 300);
@@ -21283,16 +22276,21 @@ function startServer(opts = {}) {
21283
22276
  scheduleFleetInfoRefresh();
21284
22277
  }
21285
22278
  });
22279
+ updater = new UpdateController({
22280
+ dataDir,
22281
+ isIdle: () => manager.isIdle(),
22282
+ prepareExit: closeServer
22283
+ });
21286
22284
  const server = createApp(store, manager, {
21287
22285
  secretBox,
21288
22286
  notificationHub,
21289
22287
  qq,
21290
22288
  adminKey,
21291
22289
  fleet: fleetController,
21292
- host
22290
+ host,
22291
+ updater
21293
22292
  });
21294
22293
  let closed = false;
21295
- let updater = null;
21296
22294
  function closeServer() {
21297
22295
  if (closed) return;
21298
22296
  closed = true;
@@ -21325,13 +22323,6 @@ function startServer(opts = {}) {
21325
22323
  }
21326
22324
  process.on("SIGINT", () => shutdown("SIGINT"));
21327
22325
  process.on("SIGTERM", () => shutdown("SIGTERM"));
21328
- if (opts.autoUpdate) {
21329
- updater = new UpdateController({
21330
- dataDir,
21331
- isIdle: () => manager.isIdle(),
21332
- prepareExit: closeServer
21333
- });
21334
- }
21335
22326
  server.timeout = 0;
21336
22327
  server.keepAliveTimeout = 65e3;
21337
22328
  server.headersTimeout = 66e3;
@@ -21351,7 +22342,7 @@ function startServer(opts = {}) {
21351
22342
  } else {
21352
22343
  printReady(info, qq);
21353
22344
  }
21354
- updater?.start();
22345
+ if (opts.autoUpdate) updater?.start();
21355
22346
  resolve25(info);
21356
22347
  }, reject);
21357
22348
  });
@@ -22036,7 +23027,7 @@ function cycleMode(engine) {
22036
23027
  engine.permissions.mode = next;
22037
23028
  return next;
22038
23029
  }
22039
- var rl = readline3.createInterface({
23030
+ var rl = readline4.createInterface({
22040
23031
  input: process.stdin,
22041
23032
  output: process.stdout
22042
23033
  });
@@ -22516,7 +23507,7 @@ async function offerTerminalSetup(engine, canUseAgent) {
22516
23507
  }
22517
23508
  var KNOWN_SUBCOMMANDS = /* @__PURE__ */ new Set(["link", "serve", "update", "rename"]);
22518
23509
  function cliVersion() {
22519
- const env3 = "0.2.14"?.trim();
23510
+ const env3 = "0.2.16"?.trim();
22520
23511
  if (env3) return env3;
22521
23512
  try {
22522
23513
  const req2 = (0, import_module.createRequire)(importMetaUrl);
@@ -22779,8 +23770,8 @@ async function main() {
22779
23770
 
22780
23771
  \u{1F4CB} \u4F1A\u8BDDID: ${SESSION_ID}`);
22781
23772
  cliLog(`\u{1F4BE} \u72B6\u6001\u5DF2\u4FDD\u5B58\uFF0C\u53EF\u901A\u8FC7\u4EE5\u4E0B\u547D\u4EE4\u6062\u590D:`);
22782
- cliLog(` tsx src/cli/index.ts --resume ${SESSION_ID}`);
22783
- cliLog(` \u6216 tsx src/cli/index.ts --r ${SESSION_ID}
23773
+ cliLog(` zqagent --resume ${SESSION_ID}`);
23774
+ cliLog(` \u6216 zqagent --r ${SESSION_ID}
22784
23775
  `);
22785
23776
  rl.close();
22786
23777
  process.exit(0);