@modelzen/feishu-codex-bridge 0.2.1-win → 0.2.2-win

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.
Files changed (2) hide show
  1. package/dist/cli.js +399 -106
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2013,8 +2013,8 @@ function card(elements, opts = {}) {
2013
2013
  if (opts.streaming) {
2014
2014
  config.streaming_mode = true;
2015
2015
  config.streaming_config = {
2016
- print_frequency_ms: { default: 70 },
2017
- print_step: { default: 1 },
2016
+ print_frequency_ms: { default: 25 },
2017
+ print_step: { default: 6 },
2018
2018
  print_strategy: "fast"
2019
2019
  };
2020
2020
  }
@@ -2036,6 +2036,9 @@ function card(elements, opts = {}) {
2036
2036
  function md(content) {
2037
2037
  return { tag: "markdown", content };
2038
2038
  }
2039
+ function mdStream(content, elementId) {
2040
+ return { tag: "markdown", element_id: elementId, content };
2041
+ }
2039
2042
  function image(imgKey, alt = "") {
2040
2043
  return {
2041
2044
  tag: "img",
@@ -2585,6 +2588,7 @@ function escapeInline2(s) {
2585
2588
  var RC = {
2586
2589
  stop: "run.stop"
2587
2590
  };
2591
+ var ANSWER_EID = "answer";
2588
2592
  var REASONING_MAX = 1500;
2589
2593
  var COLLAPSE_TOOL_THRESHOLD = 3;
2590
2594
  var PROCESS_BODY_BUDGET = 22e3;
@@ -2598,14 +2602,19 @@ function renderRunning(state, rc) {
2598
2602
  const elements = [];
2599
2603
  const reasoning = reasoningContent(state);
2600
2604
  if (reasoning) elements.push(reasoningPanel(reasoning, state.reasoningActive));
2601
- const blocks = rc.showTools === false ? state.blocks.filter((b) => b.kind !== "tool") : state.blocks;
2602
- for (const group of groupBlocks(blocks)) {
2603
- if (group.kind === "text") {
2604
- if (group.content.trim()) elements.push(md(group.content));
2605
- } else {
2606
- elements.push(...renderToolGroup(group.tools, false));
2605
+ const showTools = rc.showTools !== false;
2606
+ const tools = [];
2607
+ const textParts = [];
2608
+ for (const b of state.blocks) {
2609
+ if (b.kind === "tool") {
2610
+ if (showTools) tools.push(b.tool);
2611
+ } else if (b.content.trim()) {
2612
+ textParts.push(b.content);
2607
2613
  }
2608
2614
  }
2615
+ if (tools.length > 0) elements.push(...renderToolGroup(tools, false));
2616
+ const answer = textParts.join("\n\n");
2617
+ if (answer) elements.push(mdStream(answer, ANSWER_EID));
2609
2618
  if (state.footer) elements.push(footerStatus(state.footer));
2610
2619
  if (rc.cardKey) elements.push(actions([button("\u23F9 \u7EC8\u6B62", { a: RC.stop, m: rc.cardKey }, "danger")]));
2611
2620
  return elements;
@@ -2752,16 +2761,105 @@ function truncate4(s, n) {
2752
2761
  }
2753
2762
 
2754
2763
  // src/card/run-card-stream.ts
2755
- var STREAM_THROTTLE_MS = 250;
2764
+ var STREAM_THROTTLE_MS = 150;
2756
2765
  var RunCardStream = class {
2757
2766
  cardId = "";
2758
2767
  _messageId = "";
2759
2768
  seq = 0;
2760
2769
  lastPush = 0;
2761
2770
  lastContent = "";
2771
+ // Per-turn push counters — surfaced via stats() for the stream.timing log.
2772
+ pushCount = 0;
2773
+ cardPushes = 0;
2774
+ // whole-card card.update (structure)
2775
+ elPushes = 0;
2776
+ // element cardElement.content (answer typewriter)
2777
+ totalRttMs = 0;
2778
+ maxRttMs = 0;
2779
+ // Coalesced streaming. The consume loop records the latest {card, answerEid} in
2780
+ // `pending`; a single pump() drains it — NON-BLOCKING, so consuming codex events
2781
+ // never stalls on a round-trip (awaiting each push serially drained the backlog
2782
+ // at one event per ~RTT → "已回完、飞书还在慢慢打字"). The pump pushes only the most
2783
+ // recent snapshot per round-trip: when only the answer text grew it streams that
2784
+ // via cardElement.content (typewriter), otherwise it whole-card updates.
2785
+ pending = null;
2786
+ pumpChannel = null;
2787
+ pumpPromise = null;
2788
+ // Baselines for the pump's route decision (structure unchanged + answer grew?).
2789
+ lastStructureSig = "";
2790
+ lastAnswerText = "";
2762
2791
  get messageId() {
2763
2792
  return this._messageId;
2764
2793
  }
2794
+ /** Push counts (whole-card vs element) + round-trip stats for this card. */
2795
+ stats() {
2796
+ return {
2797
+ pushCount: this.pushCount,
2798
+ cardPushes: this.cardPushes,
2799
+ elPushes: this.elPushes,
2800
+ totalRttMs: this.totalRttMs,
2801
+ maxRttMs: this.maxRttMs
2802
+ };
2803
+ }
2804
+ /**
2805
+ * Record the latest card and ensure the pump is running. Returns immediately;
2806
+ * calls that arrive while a push is in flight collapse into a single push of
2807
+ * the most recent card once that round-trip completes. Use this from the
2808
+ * event-consume loop instead of awaiting {@link streamCard} per event.
2809
+ */
2810
+ streamCoalesced(channel, fullCard, answerEid) {
2811
+ this.pending = { card: fullCard, answerEid };
2812
+ this.pumpChannel = channel;
2813
+ if (!this.pumpPromise) this.pumpPromise = this.pump();
2814
+ }
2815
+ /** Await any in-flight coalesced push so the final streaming frame lands (and
2816
+ * `seq` stays ordered) before the terminal update. Call after the loop ends. */
2817
+ async drain() {
2818
+ if (this.pumpPromise) await this.pumpPromise;
2819
+ }
2820
+ async pump() {
2821
+ try {
2822
+ while (this.pending && this.pumpChannel) {
2823
+ const { card: card2, answerEid } = this.pending;
2824
+ this.pending = null;
2825
+ const t0 = Date.now();
2826
+ const answer = answerEid ? answerContent(card2, answerEid) : null;
2827
+ const sig = structureSig(card2, answerEid);
2828
+ if (answerEid && answer !== null && sig === this.lastStructureSig && answer !== this.lastAnswerText && answer.startsWith(this.lastAnswerText)) {
2829
+ await this.streamElement(this.pumpChannel, answerEid, answer);
2830
+ this.lastAnswerText = answer;
2831
+ } else {
2832
+ await this.streamCard(this.pumpChannel, card2, true);
2833
+ this.lastStructureSig = sig;
2834
+ this.lastAnswerText = answer ?? "";
2835
+ }
2836
+ const gap = STREAM_THROTTLE_MS - (Date.now() - t0);
2837
+ if (this.pending && gap > 0) await new Promise((r) => setTimeout(r, gap));
2838
+ }
2839
+ } finally {
2840
+ this.pumpPromise = null;
2841
+ }
2842
+ }
2843
+ /** Element-level streaming push (cardkit.v1.cardElement.content): the answer
2844
+ * element's accumulated full text. Feishu diffs the prefix and types the delta
2845
+ * per the card's streaming_config. Needs streaming_mode on the card. */
2846
+ async streamElement(channel, elementId, content) {
2847
+ if (!this.cardId) return;
2848
+ const t0 = Date.now();
2849
+ try {
2850
+ await channel.rawClient.cardkit.v1.cardElement.content({
2851
+ path: { card_id: this.cardId, element_id: elementId },
2852
+ data: { content, sequence: ++this.seq, uuid: `e_${this.cardId}_${this.seq}` }
2853
+ });
2854
+ const rtt = Date.now() - t0;
2855
+ this.pushCount++;
2856
+ this.elPushes++;
2857
+ this.totalRttMs += rtt;
2858
+ if (rtt > this.maxRttMs) this.maxRttMs = rtt;
2859
+ } catch (err) {
2860
+ log.fail("card", err, { phase: "run-stream-el", cardId: this.cardId, seq: this.seq });
2861
+ }
2862
+ }
2765
2863
  /** Create the entity from the initial (running) card and send a message
2766
2864
  * referencing it by card_id. Returns the carrier message id. */
2767
2865
  async create(channel, chatId, initialCard, opts) {
@@ -2803,11 +2901,17 @@ var RunCardStream = class {
2803
2901
  if (!force && now - this.lastPush < STREAM_THROTTLE_MS) return;
2804
2902
  this.lastPush = now;
2805
2903
  this.lastContent = data;
2904
+ const t0 = Date.now();
2806
2905
  try {
2807
2906
  await channel.rawClient.cardkit.v1.card.update({
2808
2907
  path: { card_id: this.cardId },
2809
2908
  data: { card: { type: "card_json", data }, sequence: ++this.seq, uuid: `s_${this.cardId}_${this.seq}` }
2810
2909
  });
2910
+ const rtt = Date.now() - t0;
2911
+ this.pushCount++;
2912
+ this.cardPushes++;
2913
+ this.totalRttMs += rtt;
2914
+ if (rtt > this.maxRttMs) this.maxRttMs = rtt;
2811
2915
  } catch (err) {
2812
2916
  log.fail("card", err, { phase: "run-stream", cardId: this.cardId, seq: this.seq });
2813
2917
  }
@@ -2837,6 +2941,21 @@ var RunCardStream = class {
2837
2941
  }
2838
2942
  }
2839
2943
  };
2944
+ function answerContent(card2, eid) {
2945
+ const els = card2.body?.elements;
2946
+ if (!Array.isArray(els)) return null;
2947
+ for (const el of els) {
2948
+ if (el && el.element_id === eid) return typeof el.content === "string" ? el.content : "";
2949
+ }
2950
+ return null;
2951
+ }
2952
+ function structureSig(card2, eid) {
2953
+ const body = card2.body;
2954
+ const els = body?.elements;
2955
+ if (!eid || !Array.isArray(els)) return JSON.stringify(card2);
2956
+ const blanked = els.map((el) => el && el.element_id === eid ? { ...el, content: "" } : el);
2957
+ return JSON.stringify({ ...card2, body: { ...body, elements: blanked } });
2958
+ }
2840
2959
 
2841
2960
  // src/card/outbound-images.ts
2842
2961
  import { readFile as readFile5, stat as stat2 } from "fs/promises";
@@ -3438,8 +3557,8 @@ function buildGroupSettingsCard(project) {
3438
3557
 
3439
3558
  // src/service/update.ts
3440
3559
  import { execFile, spawn as spawn4 } from "child_process";
3441
- import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
3442
- import { dirname as dirname8, join as join10, resolve as resolve5 } from "path";
3560
+ import { existsSync as existsSync7, readFileSync as readFileSync2 } from "fs";
3561
+ import { dirname as dirname10, join as join11, resolve as resolve5 } from "path";
3443
3562
  import { fileURLToPath as fileURLToPath4 } from "url";
3444
3563
  import { promisify } from "util";
3445
3564
 
@@ -3448,12 +3567,14 @@ import { spawn as spawn3, spawnSync } from "child_process";
3448
3567
  import { existsSync as existsSync4 } from "fs";
3449
3568
  import { mkdir as mkdir6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
3450
3569
  import { homedir as homedir3, userInfo as userInfo2 } from "os";
3451
- import { dirname as dirname6, join as join8, resolve as resolve3 } from "path";
3452
- import { fileURLToPath as fileURLToPath2 } from "url";
3570
+ import { dirname as dirname7, join as join8, resolve as resolve4 } from "path";
3571
+ import { fileURLToPath as fileURLToPath3 } from "url";
3453
3572
 
3454
3573
  // src/service/common.ts
3455
- import { appendFile, mkdir as mkdir5 } from "fs/promises";
3456
- import { join as join7 } from "path";
3574
+ import { createReadStream, statSync } from "fs";
3575
+ import { appendFile, mkdir as mkdir5, readFile as readFile7 } from "fs/promises";
3576
+ import { dirname as dirname6, join as join7, resolve as resolve3 } from "path";
3577
+ import { fileURLToPath as fileURLToPath2 } from "url";
3457
3578
  function serviceStdoutPath() {
3458
3579
  return join7(paths.appDir, "service.log");
3459
3580
  }
@@ -3465,22 +3586,76 @@ async function ensureLogFiles() {
3465
3586
  await appendFile(serviceStdoutPath(), "");
3466
3587
  await appendFile(serviceStderrPath(), "");
3467
3588
  }
3589
+ function resolveCliBinPath() {
3590
+ const distDir = dirname6(fileURLToPath2(import.meta.url));
3591
+ return resolve3(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3592
+ }
3593
+ async function tailServiceLogs(follow) {
3594
+ await ensureLogFiles();
3595
+ const files = [serviceStdoutPath(), serviceStderrPath()];
3596
+ for (const f of files) {
3597
+ const tail = await lastLines(f, 100);
3598
+ if (tail) process.stdout.write(`
3599
+ ===== ${f} =====
3600
+ ${tail}
3601
+ `);
3602
+ }
3603
+ if (!follow) return;
3604
+ const offsets = new Map(files.map((f) => [f, fileSize(f)]));
3605
+ await new Promise((resolvePromise) => {
3606
+ const onSigint = () => {
3607
+ clearInterval(timer);
3608
+ process.off("SIGINT", onSigint);
3609
+ resolvePromise();
3610
+ };
3611
+ process.on("SIGINT", onSigint);
3612
+ const timer = setInterval(() => {
3613
+ for (const f of files) {
3614
+ const size = fileSize(f);
3615
+ const from = offsets.get(f) ?? 0;
3616
+ if (size > from) {
3617
+ offsets.set(f, size);
3618
+ createReadStream(f, { start: from, end: size - 1, encoding: "utf8" }).pipe(process.stdout, {
3619
+ end: false
3620
+ });
3621
+ } else if (size < from) {
3622
+ offsets.set(f, size);
3623
+ }
3624
+ }
3625
+ }, 700);
3626
+ });
3627
+ }
3628
+ function fileSize(file) {
3629
+ try {
3630
+ return statSync(file).size;
3631
+ } catch {
3632
+ return 0;
3633
+ }
3634
+ }
3635
+ async function lastLines(file, n) {
3636
+ try {
3637
+ const text = await readFile7(file, "utf8");
3638
+ return text.split("\n").slice(-n - 1).join("\n").trimEnd();
3639
+ } catch {
3640
+ return "";
3641
+ }
3642
+ }
3468
3643
 
3469
3644
  // src/service/launchd.ts
3470
3645
  var LAUNCHD_LABEL = "ai.feishu-codex-bridge.bot";
3471
3646
  function launchAgentPlistPath() {
3472
3647
  return join8(homedir3(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
3473
3648
  }
3474
- function resolveCliBinPath() {
3475
- const distDir = dirname6(fileURLToPath2(import.meta.url));
3476
- return resolve3(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3649
+ function resolveCliBinPath2() {
3650
+ const distDir = dirname7(fileURLToPath3(import.meta.url));
3651
+ return resolve4(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3477
3652
  }
3478
3653
  function escapeXml(value) {
3479
3654
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3480
3655
  }
3481
3656
  function buildPlist() {
3482
3657
  const nodePath = process.execPath;
3483
- const cliBinPath = resolveCliBinPath();
3658
+ const cliBinPath = resolveCliBinPath2();
3484
3659
  const pathEnv = process.env.PATH ?? "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
3485
3660
  return `<?xml version="1.0" encoding="UTF-8"?>
3486
3661
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -3513,7 +3688,7 @@ function buildPlist() {
3513
3688
  }
3514
3689
  async function installLaunchd() {
3515
3690
  const plistPath = launchAgentPlistPath();
3516
- await mkdir6(dirname6(plistPath), { recursive: true });
3691
+ await mkdir6(dirname7(plistPath), { recursive: true });
3517
3692
  await ensureLogFiles();
3518
3693
  await writeFile5(plistPath, buildPlist(), "utf8");
3519
3694
  if (isLoaded()) {
@@ -3619,21 +3794,16 @@ function launchctlError(command, result) {
3619
3794
 
3620
3795
  // src/service/schtasks.ts
3621
3796
  import { spawnSync as spawnSync2 } from "child_process";
3622
- import { createReadStream, existsSync as existsSync5, statSync } from "fs";
3623
- import { mkdir as mkdir7, readFile as readFile7, rm as rm3, writeFile as writeFile6 } from "fs/promises";
3624
- import { dirname as dirname7, join as join9, resolve as resolve4 } from "path";
3625
- import { fileURLToPath as fileURLToPath3 } from "url";
3797
+ import { existsSync as existsSync5 } from "fs";
3798
+ import { mkdir as mkdir7, rm as rm3, writeFile as writeFile6 } from "fs/promises";
3799
+ import { dirname as dirname8, join as join9 } from "path";
3626
3800
  var WINDOWS_TASK_NAME = "feishu-codex-bridge";
3627
3801
  function launcherCmdPath() {
3628
3802
  return join9(paths.appDir, "service-launcher.cmd");
3629
3803
  }
3630
- function resolveCliBinPath2() {
3631
- const distDir = dirname7(fileURLToPath3(import.meta.url));
3632
- return resolve4(distDir, "..", "bin", "feishu-codex-bridge.mjs");
3633
- }
3634
3804
  function buildLauncherCmd() {
3635
3805
  const nodePath = process.execPath;
3636
- const cliBinPath = resolveCliBinPath2();
3806
+ const cliBinPath = resolveCliBinPath();
3637
3807
  const pathEnv = process.env.PATH ?? "";
3638
3808
  return [
3639
3809
  "@echo off",
@@ -3657,7 +3827,7 @@ function schtasksError(command, r) {
3657
3827
  }
3658
3828
  async function writeLauncherCmd() {
3659
3829
  const cmdPath = launcherCmdPath();
3660
- await mkdir7(dirname7(cmdPath), { recursive: true });
3830
+ await mkdir7(dirname8(cmdPath), { recursive: true });
3661
3831
  await ensureLogFiles();
3662
3832
  await writeFile6(cmdPath, buildLauncherCmd(), "utf8");
3663
3833
  }
@@ -3735,56 +3905,124 @@ async function waitUntilStopped(timeoutMs = 5e3) {
3735
3905
  }
3736
3906
  return false;
3737
3907
  }
3738
- async function tailSchtaskLogs(follow) {
3908
+
3909
+ // src/service/systemd.ts
3910
+ import { spawnSync as spawnSync3 } from "child_process";
3911
+ import { existsSync as existsSync6 } from "fs";
3912
+ import { mkdir as mkdir8, rm as rm4, writeFile as writeFile7 } from "fs/promises";
3913
+ import { homedir as homedir4 } from "os";
3914
+ import { dirname as dirname9, join as join10 } from "path";
3915
+ var SYSTEMD_UNIT_NAME = "feishu-codex-bridge.service";
3916
+ function systemdUnitPath() {
3917
+ const base = process.env.XDG_CONFIG_HOME ?? join10(homedir4(), ".config");
3918
+ return join10(base, "systemd", "user", SYSTEMD_UNIT_NAME);
3919
+ }
3920
+ function buildUnit() {
3921
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3922
+ const nodePath = process.execPath;
3923
+ const cliBinPath = resolveCliBinPath();
3924
+ const pathEnv = process.env.PATH ?? "";
3925
+ return `[Unit]
3926
+ Description=feishu-codex-bridge bot
3927
+ After=network-online.target
3928
+ Wants=network-online.target
3929
+
3930
+ [Service]
3931
+ Type=simple
3932
+ ExecStart="${esc(nodePath)}" "${esc(cliBinPath)}" run
3933
+ Restart=always
3934
+ RestartSec=5
3935
+ StandardOutput=append:${serviceStdoutPath()}
3936
+ StandardError=append:${serviceStderrPath()}
3937
+ Environment="PATH=${esc(pathEnv)}"
3938
+
3939
+ [Install]
3940
+ WantedBy=default.target
3941
+ `;
3942
+ }
3943
+ function runSystemctl(args) {
3944
+ const r = spawnSync3("systemctl", ["--user", ...args], { encoding: "utf8" });
3945
+ return {
3946
+ ok: r.status === 0,
3947
+ status: r.status,
3948
+ stdout: r.stdout ?? "",
3949
+ stderr: (r.error ? `${r.error.message}
3950
+ ` : "") + (r.stderr ?? "")
3951
+ };
3952
+ }
3953
+ function systemctlError(command, r) {
3954
+ const out = [r.stderr.trim(), r.stdout.trim()].filter(Boolean).join("\n");
3955
+ return new Error(`${command} \u5931\u8D25\uFF08exit ${r.status ?? "unknown"}\uFF09${out ? `\uFF1A${out}` : ""}`);
3956
+ }
3957
+ function systemdAvailable() {
3958
+ const r = spawnSync3("systemctl", ["--user", "is-system-running"], { encoding: "utf8" });
3959
+ if (r.error) return false;
3960
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
3961
+ return !/not been booted with systemd|Failed to connect to (the )?bus|Failed to (connect|get) D-?Bus/i.test(out);
3962
+ }
3963
+ function ensureSystemdOrThrow() {
3964
+ if (systemdAvailable()) return;
3965
+ throw new Error(
3966
+ "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684\u7528\u6237\u7EA7 systemd\u3002WSL \u9700\u5728 /etc/wsl.conf \u5199\u5165 `[boot]\\nsystemd=true` \u540E\u6267\u884C `wsl --shutdown` \u91CD\u542F\uFF1B\u6216\u76F4\u63A5\u7528 `feishu-codex-bridge run` \u524D\u53F0\u8FD0\u884C\uFF08\u65E0\u9700\u540E\u53F0\u670D\u52A1\uFF09\u3002"
3967
+ );
3968
+ }
3969
+ async function installSystemd() {
3970
+ ensureSystemdOrThrow();
3971
+ const unitPath = systemdUnitPath();
3972
+ await mkdir8(dirname9(unitPath), { recursive: true });
3739
3973
  await ensureLogFiles();
3740
- const files = [serviceStdoutPath(), serviceStderrPath()];
3741
- for (const f of files) {
3742
- const tail = await lastLines(f, 100);
3743
- if (tail) process.stdout.write(`
3744
- ===== ${f} =====
3745
- ${tail}
3746
- `);
3747
- }
3748
- if (!follow) return;
3749
- const offsets = new Map(files.map((f) => [f, fileSize(f)]));
3750
- await new Promise((resolvePromise) => {
3751
- const onSigint = () => {
3752
- clearInterval(timer);
3753
- process.off("SIGINT", onSigint);
3754
- resolvePromise();
3755
- };
3756
- process.on("SIGINT", onSigint);
3757
- const timer = setInterval(() => {
3758
- for (const f of files) {
3759
- const size = fileSize(f);
3760
- const from = offsets.get(f) ?? 0;
3761
- if (size > from) {
3762
- offsets.set(f, size);
3763
- createReadStream(f, { start: from, end: size - 1, encoding: "utf8" }).pipe(process.stdout, {
3764
- end: false
3765
- });
3766
- } else if (size < from) {
3767
- offsets.set(f, size);
3768
- }
3769
- }
3770
- }, 700);
3771
- });
3974
+ await writeFile7(unitPath, buildUnit(), "utf8");
3975
+ const reload = runSystemctl(["daemon-reload"]);
3976
+ if (!reload.ok) throw systemctlError("systemctl --user daemon-reload", reload);
3977
+ const enable = runSystemctl(["enable", "--now", SYSTEMD_UNIT_NAME]);
3978
+ if (!enable.ok) throw systemctlError("systemctl --user enable --now", enable);
3979
+ return statusSystemd();
3980
+ }
3981
+ async function uninstallSystemd() {
3982
+ if (systemdAvailable() && unitExists()) {
3983
+ runSystemctl(["disable", "--now", SYSTEMD_UNIT_NAME]);
3984
+ }
3985
+ await rm4(systemdUnitPath(), { force: true });
3986
+ if (systemdAvailable()) runSystemctl(["daemon-reload"]);
3987
+ }
3988
+ async function restartSystemd() {
3989
+ ensureSystemdOrThrow();
3990
+ if (!unitExists()) {
3991
+ throw new Error(`systemd unit \u672A\u5B89\u88C5\uFF1A${systemdUnitPath()}\uFF08\u5148\u8FD0\u884C \`feishu-codex-bridge start\`\uFF09`);
3992
+ }
3993
+ const restart = runSystemctl(["restart", SYSTEMD_UNIT_NAME]);
3994
+ if (!restart.ok) throw systemctlError("systemctl --user restart", restart);
3995
+ return statusSystemd();
3996
+ }
3997
+ function statusSystemd() {
3998
+ const installed = unitExists();
3999
+ const raw = installed && systemdAvailable() ? describeService() : "";
4000
+ return {
4001
+ platformName: "systemd (Linux user)",
4002
+ installed,
4003
+ running: systemdActive(),
4004
+ servicePath: systemdUnitPath(),
4005
+ stdoutPath: serviceStdoutPath(),
4006
+ stderrPath: serviceStderrPath(),
4007
+ pid: raw.match(/Main PID:\s*(\d+)/)?.[1],
4008
+ // On an inactive unit the "Process: <pid> ExecStart=... status=<n>" line
4009
+ // carries the last exit code.
4010
+ lastExit: raw.match(/Process:\s+\d+\s+ExecStart=.*status=(\d+)/)?.[1],
4011
+ raw
4012
+ };
3772
4013
  }
3773
- function fileSize(file) {
3774
- try {
3775
- return statSync(file).size;
3776
- } catch {
3777
- return 0;
3778
- }
4014
+ function unitExists() {
4015
+ return existsSync6(systemdUnitPath());
3779
4016
  }
3780
- async function lastLines(file, n) {
3781
- try {
3782
- const text = await readFile7(file, "utf8");
3783
- const lines = text.split("\n");
3784
- return lines.slice(-n - 1).join("\n").trimEnd();
3785
- } catch {
3786
- return "";
3787
- }
4017
+ function systemdActive() {
4018
+ const r = spawnSync3("systemctl", ["--user", "is-active", SYSTEMD_UNIT_NAME], {
4019
+ stdio: ["ignore", "ignore", "ignore"]
4020
+ });
4021
+ return r.status === 0;
4022
+ }
4023
+ function describeService() {
4024
+ const r = runSystemctl(["status", SYSTEMD_UNIT_NAME, "--no-pager"]);
4025
+ return r.stdout || r.stderr || "";
3788
4026
  }
3789
4027
 
3790
4028
  // src/service/adapter.ts
@@ -3804,17 +4042,27 @@ function getServiceAdapter() {
3804
4042
  uninstall: uninstallSchtask,
3805
4043
  status: async () => statusSchtask(),
3806
4044
  restart: restartSchtask,
3807
- logs: tailSchtaskLogs
4045
+ logs: tailServiceLogs
4046
+ };
4047
+ }
4048
+ if (process.platform === "linux") {
4049
+ return {
4050
+ install: installSystemd,
4051
+ uninstall: uninstallSystemd,
4052
+ status: async () => statusSystemd(),
4053
+ restart: restartSystemd,
4054
+ logs: tailServiceLogs
3808
4055
  };
3809
4056
  }
3810
4057
  throw new Error(
3811
- "service\uFF1A\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u540E\u53F0\u670D\u52A1\uFF08\u4EC5 macOS launchd / Windows \u8BA1\u5212\u4EFB\u52A1\uFF09\u3002\u8BF7\u7528 `feishu-codex-bridge run` \u524D\u53F0\u8FD0\u884C" + (process.platform === "linux" ? "\uFF1BLinux systemd \u652F\u6301\u540E\u7EED\u63D0\u4F9B\u3002" : "\u3002")
4058
+ "service\uFF1A\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u540E\u53F0\u670D\u52A1\uFF08\u4EC5 macOS launchd / Windows \u8BA1\u5212\u4EFB\u52A1 / Linux systemd\uFF09\u3002\u8BF7\u7528 `feishu-codex-bridge run` \u524D\u53F0\u8FD0\u884C\u3002"
3812
4059
  );
3813
4060
  }
3814
4061
  function isServiceRunning() {
3815
4062
  try {
3816
4063
  if (process.platform === "darwin") return isLoaded();
3817
4064
  if (process.platform === "win32") return schtaskRunning();
4065
+ if (process.platform === "linux") return systemdActive();
3818
4066
  } catch {
3819
4067
  }
3820
4068
  return false;
@@ -3824,11 +4072,11 @@ function isServiceRunning() {
3824
4072
  var execFileP = promisify(execFile);
3825
4073
  var NPM = process.platform === "win32" ? "npm.cmd" : "npm";
3826
4074
  function pkgRoot() {
3827
- return resolve5(dirname8(fileURLToPath4(import.meta.url)), "..");
4075
+ return resolve5(dirname10(fileURLToPath4(import.meta.url)), "..");
3828
4076
  }
3829
4077
  function pkgJson() {
3830
4078
  try {
3831
- return JSON.parse(readFileSync2(join10(pkgRoot(), "package.json"), "utf8"));
4079
+ return JSON.parse(readFileSync2(join11(pkgRoot(), "package.json"), "utf8"));
3832
4080
  } catch {
3833
4081
  return {};
3834
4082
  }
@@ -3840,7 +4088,7 @@ function packageName() {
3840
4088
  return pkgJson().name ?? "@modelzen/feishu-codex-bridge";
3841
4089
  }
3842
4090
  function isDevSource() {
3843
- return existsSync6(join10(pkgRoot(), ".git"));
4091
+ return existsSync7(join11(pkgRoot(), ".git"));
3844
4092
  }
3845
4093
  function isNewer(a, b) {
3846
4094
  const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -3886,9 +4134,9 @@ async function restartDaemon() {
3886
4134
  }
3887
4135
 
3888
4136
  // src/project/lifecycle.ts
3889
- import { mkdir as mkdir8 } from "fs/promises";
3890
- import { existsSync as existsSync7 } from "fs";
3891
- import { isAbsolute as isAbsolute2, join as join11, resolve as resolve6 } from "path";
4137
+ import { mkdir as mkdir9 } from "fs/promises";
4138
+ import { existsSync as existsSync8 } from "fs";
4139
+ import { isAbsolute as isAbsolute2, join as join12, resolve as resolve6 } from "path";
3892
4140
 
3893
4141
  // src/project/git-info.ts
3894
4142
  import { execFile as execFile2 } from "child_process";
@@ -4024,11 +4272,11 @@ async function onboardGroup(channel, project) {
4024
4272
  async function resolveCwd(name, existingPath) {
4025
4273
  if (existingPath) {
4026
4274
  const cwd2 = isAbsolute2(existingPath) ? existingPath : resolve6(existingPath);
4027
- if (!existsSync7(cwd2)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd2}`);
4275
+ if (!existsSync8(cwd2)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd2}`);
4028
4276
  return { cwd: cwd2, blank: false };
4029
4277
  }
4030
- const cwd = join11(paths.projectsRootDir, name);
4031
- await mkdir8(cwd, { recursive: true });
4278
+ const cwd = join12(paths.projectsRootDir, name);
4279
+ await mkdir9(cwd, { recursive: true });
4032
4280
  return { cwd, blank: true };
4033
4281
  }
4034
4282
  async function createProject(channel, input2) {
@@ -4095,8 +4343,8 @@ async function leaveChat(channel, chatId) {
4095
4343
  }
4096
4344
 
4097
4345
  // src/bot/session-store.ts
4098
- import { mkdir as mkdir9, readFile as readFile8, rename as rename5, writeFile as writeFile7 } from "fs/promises";
4099
- import { dirname as dirname9 } from "path";
4346
+ import { mkdir as mkdir10, readFile as readFile8, rename as rename5, writeFile as writeFile8 } from "fs/promises";
4347
+ import { dirname as dirname11 } from "path";
4100
4348
  var FILE_VERSION3 = 1;
4101
4349
  async function read2() {
4102
4350
  try {
@@ -4109,10 +4357,10 @@ async function read2() {
4109
4357
  }
4110
4358
  }
4111
4359
  async function write2(sessions) {
4112
- await mkdir9(dirname9(paths.sessionsFile), { recursive: true });
4360
+ await mkdir10(dirname11(paths.sessionsFile), { recursive: true });
4113
4361
  const tmp = `${paths.sessionsFile}.tmp-${process.pid}`;
4114
4362
  const body = { version: FILE_VERSION3, sessions };
4115
- await writeFile7(tmp, `${JSON.stringify(body, null, 2)}
4363
+ await writeFile8(tmp, `${JSON.stringify(body, null, 2)}
4116
4364
  `, "utf8");
4117
4365
  await rename5(tmp, paths.sessionsFile);
4118
4366
  }
@@ -4156,8 +4404,8 @@ async function handleDmConsole(channel, cfg, msg) {
4156
4404
  }
4157
4405
 
4158
4406
  // src/bot/media.ts
4159
- import { mkdir as mkdir10, readdir as readdir2, rm as rm4, stat as stat3 } from "fs/promises";
4160
- import { join as join12 } from "path";
4407
+ import { mkdir as mkdir11, readdir as readdir2, rm as rm5, stat as stat3 } from "fs/promises";
4408
+ import { join as join13 } from "path";
4161
4409
  var MAX_IMAGES2 = 9;
4162
4410
  var MEDIA_TTL_MS = 60 * 6e4;
4163
4411
  var EXT_BY_CONTENT_TYPE = {
@@ -4186,7 +4434,7 @@ async function collectInboundImages(channel, msg) {
4186
4434
  if (refs.length === 0) return [];
4187
4435
  await pruneOldMedia();
4188
4436
  try {
4189
- await mkdir10(paths.mediaDir, { recursive: true });
4437
+ await mkdir11(paths.mediaDir, { recursive: true });
4190
4438
  } catch {
4191
4439
  }
4192
4440
  const out = [];
@@ -4262,7 +4510,7 @@ async function downloadOne(channel, ref, index) {
4262
4510
  params: { type: "image" }
4263
4511
  });
4264
4512
  const ext = extFromHeaders(res.headers);
4265
- const file = join12(paths.mediaDir, `${safeName(ref.fileKey)}-${index}.${ext}`);
4513
+ const file = join13(paths.mediaDir, `${safeName(ref.fileKey)}-${index}.${ext}`);
4266
4514
  await res.writeFile(file);
4267
4515
  return file;
4268
4516
  } catch (err) {
@@ -4296,10 +4544,10 @@ async function pruneOldMedia() {
4296
4544
  }
4297
4545
  const cutoff = Date.now() - MEDIA_TTL_MS;
4298
4546
  for (const name of entries) {
4299
- const file = join12(paths.mediaDir, name);
4547
+ const file = join13(paths.mediaDir, name);
4300
4548
  try {
4301
4549
  const st = await stat3(file);
4302
- if (st.mtimeMs < cutoff) await rm4(file, { force: true });
4550
+ if (st.mtimeMs < cutoff) await rm5(file, { force: true });
4303
4551
  } catch {
4304
4552
  }
4305
4553
  }
@@ -5348,11 +5596,29 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
5348
5596
  },
5349
5597
  stopSignal
5350
5598
  );
5599
+ const tStart = Date.now();
5600
+ let firstEvAt = 0;
5601
+ let firstTextAt = 0;
5602
+ let lastEvAt = tStart;
5603
+ let evCount = 0;
5604
+ let textChars = 0;
5351
5605
  for await (const ev of guarded) {
5606
+ const tEv = Date.now();
5607
+ if (!firstEvAt) firstEvAt = tEv;
5608
+ const et = ev.type;
5609
+ if (et === "text_delta") {
5610
+ if (!firstTextAt) firstTextAt = tEv;
5611
+ const d = ev.delta;
5612
+ if (typeof d === "string") textChars += d.length;
5613
+ }
5614
+ lastEvAt = tEv;
5615
+ evCount++;
5352
5616
  render.apply(ev);
5353
5617
  rc.rs = render.snapshot();
5354
- await stream2.streamCard(channel, buildRunCard(rc));
5618
+ stream2.streamCoalesced(channel, buildRunCard(rc), ANSWER_EID);
5355
5619
  }
5620
+ const doneAt = Date.now();
5621
+ await stream2.drain();
5356
5622
  state.interrupt = void 0;
5357
5623
  const killed = interrupted || timedOut;
5358
5624
  if (timedOut) render.timeout(Math.max(1, Math.round(idleMs / 6e4)));
@@ -5373,6 +5639,25 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
5373
5639
  rc.images = await uploadOutboundImages(channel, imgSources, opts.cwd ?? fallbackCwd);
5374
5640
  }
5375
5641
  await stream2.updateCard(channel, buildRunCard(rc));
5642
+ {
5643
+ const terminalAt = Date.now();
5644
+ const st = stream2.stats();
5645
+ log.info("stream", "timing", {
5646
+ firstEv: firstEvAt ? firstEvAt - tStart : -1,
5647
+ firstText: firstTextAt ? firstTextAt - tStart : -1,
5648
+ lastEv: lastEvAt - tStart,
5649
+ done: doneAt - tStart,
5650
+ terminal: terminalAt - tStart,
5651
+ doneToTerminal: terminalAt - doneAt,
5652
+ events: evCount,
5653
+ textChars,
5654
+ pushes: st.pushCount,
5655
+ cardPushes: st.cardPushes,
5656
+ elPushes: st.elPushes,
5657
+ rttAvg: st.pushCount ? Math.round(st.totalRttMs / st.pushCount) : 0,
5658
+ rttMax: st.maxRttMs
5659
+ });
5660
+ }
5376
5661
  runsByCard.delete(cardMsgId);
5377
5662
  promoteCard(finalMsgId, rc);
5378
5663
  for (const fence of fences) {
@@ -5634,7 +5919,7 @@ async function startBridge(opts) {
5634
5919
 
5635
5920
  // src/core/single-instance.ts
5636
5921
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync } from "fs";
5637
- import { dirname as dirname10 } from "path";
5922
+ import { dirname as dirname12 } from "path";
5638
5923
  var BridgeAlreadyRunningError = class extends Error {
5639
5924
  constructor(pid) {
5640
5925
  super(
@@ -5663,7 +5948,7 @@ function acquireSingleInstanceLock(appId) {
5663
5948
  } catch (err) {
5664
5949
  if (err instanceof BridgeAlreadyRunningError) throw err;
5665
5950
  }
5666
- mkdirSync2(dirname10(file), { recursive: true });
5951
+ mkdirSync2(dirname12(file), { recursive: true });
5667
5952
  const record = { pid: process.pid, appId, startedAt: Date.now() };
5668
5953
  writeFileSync(file, `${JSON.stringify(record)}
5669
5954
  `, "utf8");
@@ -5732,9 +6017,7 @@ async function runStart() {
5732
6017
  return;
5733
6018
  }
5734
6019
  const status = await getServiceAdapter().install();
5735
- console.log(
5736
- process.platform === "win32" ? "\u2713 \u540E\u53F0\u670D\u52A1\u5DF2\u5B89\u88C5\u5E76\u542F\u52A8\uFF08\u767B\u5F55\u81EA\u542F\uFF1B\u6CE8\u610F\uFF1AWindows \u8BA1\u5212\u4EFB\u52A1\u65E0\u5D29\u6E83\u81EA\u52A8\u62C9\u8D77\uFF09\u3002" : "\u2713 \u540E\u53F0\u670D\u52A1\u5DF2\u5B89\u88C5\u5E76\u542F\u52A8\uFF08\u5F00\u673A\u81EA\u542F\u3001\u5D29\u6E83\u81EA\u52A8\u62C9\u8D77\uFF09\u3002"
5737
- );
6020
+ console.log(installedNote());
5738
6021
  printStatus(status);
5739
6022
  }
5740
6023
  async function runStop() {
@@ -5752,6 +6035,16 @@ async function runStatus() {
5752
6035
  async function runLogs(follow) {
5753
6036
  await getServiceAdapter().logs(follow);
5754
6037
  }
6038
+ function installedNote() {
6039
+ switch (process.platform) {
6040
+ case "win32":
6041
+ return "\u2713 \u540E\u53F0\u670D\u52A1\u5DF2\u5B89\u88C5\u5E76\u542F\u52A8\uFF08\u767B\u5F55\u81EA\u542F\uFF1B\u6CE8\u610F\uFF1AWindows \u8BA1\u5212\u4EFB\u52A1\u65E0\u5D29\u6E83\u81EA\u52A8\u62C9\u8D77\uFF09\u3002";
6042
+ case "linux":
6043
+ return "\u2713 \u540E\u53F0\u670D\u52A1\u5DF2\u5B89\u88C5\u5E76\u542F\u52A8\uFF08\u767B\u5F55\u81EA\u542F\u3001\u5D29\u6E83\u81EA\u52A8\u62C9\u8D77\uFF09\u3002\n \u63D0\u793A\uFF1A\u6CE8\u9500\u540E\u4ECD\u4FDD\u6301\u8FD0\u884C\u9700\u6267\u884C\u4E00\u6B21 `loginctl enable-linger $USER`\u3002";
6044
+ default:
6045
+ return "\u2713 \u540E\u53F0\u670D\u52A1\u5DF2\u5B89\u88C5\u5E76\u542F\u52A8\uFF08\u5F00\u673A\u81EA\u542F\u3001\u5D29\u6E83\u81EA\u52A8\u62C9\u8D77\uFF09\u3002";
6046
+ }
6047
+ }
5755
6048
  function printStatus(status) {
5756
6049
  console.log(`service: ${status.platformName}`);
5757
6050
  console.log(`path: ${status.servicePath}`);
@@ -5817,7 +6110,7 @@ async function runUpdate(opts = {}) {
5817
6110
  }
5818
6111
 
5819
6112
  // src/cli/commands/bot.ts
5820
- import { rm as rm5 } from "fs/promises";
6113
+ import { rm as rm6 } from "fs/promises";
5821
6114
  async function runBotInit(name) {
5822
6115
  if (!ensureCodex()) {
5823
6116
  process.exitCode = 1;
@@ -5872,7 +6165,7 @@ async function runBotRm(name) {
5872
6165
  }
5873
6166
  const after = await removeBot(bot2.appId);
5874
6167
  await removeSecret(secretKeyForApp(bot2.appId));
5875
- await rm5(botDir(bot2.appId), { recursive: true, force: true });
6168
+ await rm6(botDir(bot2.appId), { recursive: true, force: true });
5876
6169
  console.log(`\u2713 \u5DF2\u79FB\u9664\u673A\u5668\u4EBA\u300C${bot2.name}\u300D(${bot2.appId})\uFF1A\u6CE8\u518C\u8868 + \u5BC6\u94A5 + \u72B6\u6001\u76EE\u5F55(projects/sessions)\u3002`);
5877
6170
  if (after.bots.length === 0) {
5878
6171
  console.log(" \u5DF2\u65E0\u4EFB\u4F55\u673A\u5668\u4EBA\uFF0C`bot init` \u91CD\u65B0\u521B\u5EFA\u3002");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelzen/feishu-codex-bridge",
3
- "version": "0.2.1-win",
3
+ "version": "0.2.2-win",
4
4
  "description": "Bridge Feishu/Lark messenger with local Codex via app-server (project=group, thread=session)",
5
5
  "type": "module",
6
6
  "bin": {