mslxdff 0.1.45 → 0.1.55

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.
@@ -1,14 +1,14 @@
1
- # ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
2
-
3
- The Zen upstream's thinking-mode models (deepseek-family at minimum) return
4
- `400 "The reasoning_content in the thinking mode must be passed back"` when a
5
- multi-turn request echoes an assistant message without its `reasoning_content`.
6
- Clients speaking plain OpenAI format never send that field, so the proxy writes
7
- a `" "` placeholder into assistant messages before forwarding. Scope is `all`
8
- for deepseek-family models and `tool_calls` for kimi-family models; messages
9
- that already carry non-empty `reasoning_content` are left untouched.
10
-
11
- The alternative — telling clients to manage `reasoning_content` themselves —
12
- would break standard OpenAI-compatible clients, so the proxy eats this
13
- compatibility cost instead. Matches `/root/9router` v0.5.45
1
+ # ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
2
+
3
+ The Zen upstream's thinking-mode models (deepseek-family at minimum) return
4
+ `400 "The reasoning_content in the thinking mode must be passed back"` when a
5
+ multi-turn request echoes an assistant message without its `reasoning_content`.
6
+ Clients speaking plain OpenAI format never send that field, so the proxy writes
7
+ a `" "` placeholder into assistant messages before forwarding. Scope is `all`
8
+ for deepseek-family models and `tool_calls` for kimi-family models; messages
9
+ that already carry non-empty `reasoning_content` are left untouched.
10
+
11
+ The alternative — telling clients to manage `reasoning_content` themselves —
12
+ would break standard OpenAI-compatible clients, so the proxy eats this
13
+ compatibility cost instead. Matches `/root/9router` v0.5.45
14
14
  `open-sse/utils/reasoningContentInjector.js`.
@@ -1,12 +1,12 @@
1
- # ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
2
-
3
- The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
4
- pollute clients with paid models this proxy can't serve for free. `/v1/models`
5
- therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
6
- `big-pickle`. The whitelist exists because `big-pickle` is a free model without
7
- the `-free` suffix, and a suffix-only filter would silently drop it.
8
-
9
- A plain `endsWith("-free")` filter was considered and rejected for exactly that
10
- reason. Matches `/root/9router` v0.5.45
11
- `src/app/api/providers/suggested-models/filters.js`
1
+ # ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
2
+
3
+ The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
4
+ pollute clients with paid models this proxy can't serve for free. `/v1/models`
5
+ therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
6
+ `big-pickle`. The whitelist exists because `big-pickle` is a free model without
7
+ the `-free` suffix, and a suffix-only filter would silently drop it.
8
+
9
+ A plain `endsWith("-free")` filter was considered and rejected for exactly that
10
+ reason. Matches `/root/9router` v0.5.45
11
+ `src/app/api/providers/suggested-models/filters.js`
12
12
  (`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
@@ -1,10 +1,10 @@
1
- # ADR-0003: Zero-state, no-DB, no-account local proxy
2
-
3
- Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
4
- no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
5
- stand.
6
-
7
- By design this proxy holds no database, no token store, no account rotation,
8
- and no cloud sync. A single static bearer token is the only credential, kept
9
- in a 0600 state file (see ADR-0004); everything else is stateless per-process
1
+ # ADR-0003: Zero-state, no-DB, no-account local proxy
2
+
3
+ Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
4
+ no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
5
+ stand.
6
+
7
+ By design this proxy holds no database, no token store, no account rotation,
8
+ and no cloud sync. A single static bearer token is the only credential, kept
9
+ in a 0600 state file (see ADR-0004); everything else is stateless per-process
10
10
  memory at most.
@@ -1,18 +1,18 @@
1
- # ADR-0004: Single static bearer token, persisted in a 0600 state file
2
-
3
- The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
4
- isn't an open relay. There is no account system: the token is a random
5
- `crypto` 32-byte value (hex), generated once on first run, persisted to a
6
- state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
7
- via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
8
- `mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
9
- new token, and exits (does not start the server).
10
-
11
- Auth is enforced with a constant-time string compare on
12
- `Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
13
- `/health` stays public (no token). Tokens never appear in logs.
14
-
15
- Alternatives rejected: a fixed default token (same key on every install),
16
- per-user accounts (needs a DB — that's the 9Router provisioning surface we
17
- rejected in ADR-0003), and unauthenticated local-only binding (fragile;
1
+ # ADR-0004: Single static bearer token, persisted in a 0600 state file
2
+
3
+ The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
4
+ isn't an open relay. There is no account system: the token is a random
5
+ `crypto` 32-byte value (hex), generated once on first run, persisted to a
6
+ state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
7
+ via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
8
+ `mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
9
+ new token, and exits (does not start the server).
10
+
11
+ Auth is enforced with a constant-time string compare on
12
+ `Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
13
+ `/health` stays public (no token). Tokens never appear in logs.
14
+
15
+ Alternatives rejected: a fixed default token (same key on every install),
16
+ per-user accounts (needs a DB — that's the 9Router provisioning surface we
17
+ rejected in ADR-0003), and unauthenticated local-only binding (fragile;
18
18
  a proxy relay deserves an explicit secret even on localhost).
@@ -1,51 +1,51 @@
1
- # Domain Docs
2
-
3
- How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
-
5
- ## Before exploring, read these
6
-
7
- - **`CONTEXT.md`** at the repo root, or
8
- - **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
9
- - **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
-
11
- If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
12
-
13
- ## File structure
14
-
15
- Single-context repo (most repos):
16
-
17
- ```
18
- /
19
- ├── CONTEXT.md
20
- ├── docs/adr/
21
- │ ├── 0001-event-sourced-orders.md
22
- │ └── 0002-postgres-for-write-model.md
23
- └── src/
24
- ```
25
-
26
- Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
27
-
28
- ```
29
- /
30
- ├── CONTEXT-MAP.md
31
- ├── docs/adr/ ← system-wide decisions
32
- └── src/
33
- ├── ordering/
34
- │ ├── CONTEXT.md
35
- │ └── docs/adr/ ← context-specific decisions
36
- └── billing/
37
- ├── CONTEXT.md
38
- └── docs/adr/
39
- ```
40
-
41
- ## Use the glossary's vocabulary
42
-
43
- When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
44
-
45
- If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
46
-
47
- ## Flag ADR conflicts
48
-
49
- If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
50
-
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ ## Before exploring, read these
6
+
7
+ - **`CONTEXT.md`** at the repo root, or
8
+ - **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
9
+ - **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
+
11
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
12
+
13
+ ## File structure
14
+
15
+ Single-context repo (most repos):
16
+
17
+ ```
18
+ /
19
+ ├── CONTEXT.md
20
+ ├── docs/adr/
21
+ │ ├── 0001-event-sourced-orders.md
22
+ │ └── 0002-postgres-for-write-model.md
23
+ └── src/
24
+ ```
25
+
26
+ Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
27
+
28
+ ```
29
+ /
30
+ ├── CONTEXT-MAP.md
31
+ ├── docs/adr/ ← system-wide decisions
32
+ └── src/
33
+ ├── ordering/
34
+ │ ├── CONTEXT.md
35
+ │ └── docs/adr/ ← context-specific decisions
36
+ └── billing/
37
+ ├── CONTEXT.md
38
+ └── docs/adr/
39
+ ```
40
+
41
+ ## Use the glossary's vocabulary
42
+
43
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
44
+
45
+ If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
46
+
47
+ ## Flag ADR conflicts
48
+
49
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
50
+
51
51
  > _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
@@ -1,30 +1,30 @@
1
- # Issue tracker: Local Markdown
2
-
3
- Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
4
-
5
- ## Conventions
6
-
7
- - One feature per directory: `.scratch/<feature-slug>/`
8
- - The spec is `.scratch/<feature-slug>/spec.md`
9
- - Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
10
- - Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
11
- - Comments and conversation history append to the bottom of the file under a `## Comments` heading
12
-
13
- ## When a skill says "publish to the issue tracker"
14
-
15
- Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
16
-
17
- ## When a skill says "fetch the relevant ticket"
18
-
19
- Read the file at the referenced path. The user will normally pass the path or the issue number directly.
20
-
21
- ## Wayfinding operations
22
-
23
- Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
24
-
25
- - **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
26
- - **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
27
- - **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
28
- - **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
29
- - **Claim**: set `Status: claimed` and save before any work.
1
+ # Issue tracker: Local Markdown
2
+
3
+ Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
4
+
5
+ ## Conventions
6
+
7
+ - One feature per directory: `.scratch/<feature-slug>/`
8
+ - The spec is `.scratch/<feature-slug>/spec.md`
9
+ - Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
10
+ - Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
11
+ - Comments and conversation history append to the bottom of the file under a `## Comments` heading
12
+
13
+ ## When a skill says "publish to the issue tracker"
14
+
15
+ Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
16
+
17
+ ## When a skill says "fetch the relevant ticket"
18
+
19
+ Read the file at the referenced path. The user will normally pass the path or the issue number directly.
20
+
21
+ ## Wayfinding operations
22
+
23
+ Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
24
+
25
+ - **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
26
+ - **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
27
+ - **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
28
+ - **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
29
+ - **Claim**: set `Status: claimed` and save before any work.
30
30
  - **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
@@ -1,15 +1,15 @@
1
- # Triage Labels
2
-
3
- The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
-
5
- | Label in mattpocock/skills | Label in our tracker | Meaning |
6
- | -------------------------- | -------------------- | ---------------------------------------- |
7
- | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
- | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
- | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
- | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
- | `wontfix` | `wontfix` | Will not be actioned |
12
-
13
- When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
-
1
+ # Triage Labels
2
+
3
+ The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
+
5
+ | Label in mattpocock/skills | Label in our tracker | Meaning |
6
+ | -------------------------- | -------------------- | ---------------------------------------- |
7
+ | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
+ | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
+ | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
+ | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
+ | `wontfix` | `wontfix` | Will not be actioned |
12
+
13
+ When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
+
15
15
  Edit the right-hand column to match whatever vocabulary you actually use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.45",
3
+ "version": "0.1.55",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auto.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { statSync } from "node:fs";
2
- import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, defaultStateFile } from "./state.js";
2
+ import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, loadModelPicks, saveModelPicks, defaultStateFile } from "./state.js";
3
3
 
4
4
  // 出厂默认首选模型(state.json 的 preferredModel / env MSLXDFF_PREFERRED_MODEL 可覆盖)
5
5
  export const DEFAULT_PREFERRED_MODEL = "big-pickle";
@@ -129,6 +129,8 @@ export function createAutoSelector({
129
129
  latencies: seedLatencies,
130
130
  persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
131
131
  persistLatencies = (latencies, f = file) => saveModelLatencies(latencies, f ? { file: f } : {}),
132
+ loadPicks = () => (file ? loadModelPicks({ file }) : []),
133
+ persistPicks = (picks) => (file ? saveModelPicks(picks, { file }) : picks),
132
134
  } = {}) {
133
135
  const lastErrorAt = { ...(seedErrors ?? loadModelErrors(file ? { file } : {})) };
134
136
  const latencies = { ...(seedLatencies ?? loadModelLatencies(file ? { file } : {})) };
@@ -144,13 +146,29 @@ export function createAutoSelector({
144
146
  return [...new Set(list)].filter(Boolean);
145
147
  }
146
148
 
149
+ // 勾选集 = auto 候选池白名单:只在勾选的模型里择优;空勾选或勾选中无可用模型时回退全量
150
+ async function pickedPool(list) {
151
+ const picks = loadPicks();
152
+ if (!picks.length) return list;
153
+ const pickedSet = new Set(picks);
154
+ const filtered = list.filter((id) => pickedSet.has(id));
155
+ return filtered.length ? filtered : list;
156
+ }
157
+
147
158
  async function candidates() {
148
- return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
159
+ const list = await loadList();
160
+ const pool = await pickedPool(list);
161
+ return rankModels(pool, lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
149
162
  }
150
163
 
151
164
  async function candidatesFor(requested) {
152
165
  if (!requested) return candidates();
153
166
  const list = await loadList();
167
+ // 显式指定某模型 = 认可它,自动加入勾选集(仅当它是真实上游 free 模型时,避免垃圾 id 污染)
168
+ const picks = loadPicks();
169
+ if (list.includes(requested) && !picks.includes(requested) && persistPicks) {
170
+ await persistPicks([...picks, requested]);
171
+ }
154
172
  const all = list.includes(requested) ? list : [requested, ...list];
155
173
  const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
156
174
  now: now(),
package/src/chooser.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // 交互式模型选择器的纯渲染逻辑(便于测试);键盘循环在 bin/mslxdff.js
2
2
 
3
- // items: [{ id, status?, ms?, fail?, current? }];cursor 当前高亮行
3
+ // items: [{ id, status?, ms?, fail?, current?, picked? }];cursor 当前高亮行
4
+ // multi=true 时多选勾选(picked 显示 [x]/[ ]),否则单选默认模型(当前显示 ✓)
4
5
  // 返回行数组(无 ANSI 颜色,纯文本标记,Windows 终端友好)
5
- export function renderChooser(items, cursor = 0) {
6
+ export function renderChooser(items, cursor = 0, { multi = false } = {}) {
6
7
  return items.map((it, i) => {
7
8
  const arrow = i === cursor ? "❯" : " ";
8
- const check = it.current ? " ✓ (current)" : "";
9
+ const check = multi
10
+ ? (it.picked ? " [✓]" : " [ ]")
11
+ : (it.current ? " ✓ (current)" : "");
9
12
  let state = "";
10
13
  if (it.fail) state = ` [fail: ${it.fail}]`;
11
14
  else if (it.ms != null) state = ` [${it.ms}ms]`;
@@ -14,16 +17,19 @@ export function renderChooser(items, cursor = 0) {
14
17
  });
15
18
  }
16
19
 
17
- export function renderChooserHelp() {
18
- return ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
20
+ export function renderChooserHelp(multi = false) {
21
+ return multi
22
+ ? ["", "↑/↓ move · Space toggle pick · Enter save picks · q/Esc cancel"]
23
+ : ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
19
24
  }
20
25
 
21
- // 解析按键:返回 "up" | "down" | "enter" | "cancel" | null(忽略)
26
+ // 解析按键:返回 "up" | "down" | "enter" | "cancel" | "space" | null(忽略)
22
27
  export function parseKey(str) {
23
28
  if (!str) return null;
24
29
  if (str === "\x1b[A" || str === "k") return "up";
25
30
  if (str === "\x1b[B" || str === "j") return "down";
26
31
  if (str === "\r" || str === "\n") return "enter";
32
+ if (str === " " || str === "\x1b[32") return "space"; // 空格(32=0x20 的 ASC 表示)或某些终端 terminator
27
33
  if (str === "\x1b" || str === "q" || str === "\x03") return "cancel";
28
34
  return null;
29
35
  }
package/src/daemon.js CHANGED
@@ -1,84 +1,84 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
- import { join, dirname } from "node:path";
4
- import os from "node:os";
5
- import { fileURLToPath } from "node:url";
6
-
7
- export function daemonDir() {
8
- return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
9
- }
10
-
11
- export function pidFile() {
12
- return join(daemonDir(), "daemon.pid");
13
- }
14
-
15
- export function logFile() {
16
- return join(daemonDir(), "daemon.log");
17
- }
18
-
19
- export function startDaemon(args = []) {
20
- const here = fileURLToPath(import.meta.url);
21
- const entry = here.endsWith("bin/mslxdff.js")
22
- ? here
23
- : join(dirname(here), "..", "bin", "mslxdff.js");
24
- const dir = daemonDir();
25
- mkdirSync(dir, { recursive: true });
26
- const logFd = openSync(logFile(), "a", 0o600);
27
- const env = { ...process.env, MSLXDFF_DAEMON: "1" };
28
- // a -debug foreground session wouldn't pass MSLXDFF_DEBUG to the
29
- // background daemon it restores (that flag means "print events to stdout")
30
- delete env.MSLXDFF_DEBUG;
31
- const child = spawn(process.execPath, [entry, ...args, "--daemon"], {
32
- detached: true,
33
- stdio: ["ignore", logFd, logFd],
34
- env,
35
- });
36
- child.unref();
37
- return child.pid;
38
- }
39
-
40
- export function writePid(pid, version) {
41
- const dir = daemonDir();
42
- mkdirSync(dir, { recursive: true });
43
- writeFileSync(pidFile(), version ? `${pid}\n${version}` : String(pid), { mode: 0o600 });
44
- }
45
-
46
- export function readPid() {
47
- if (!existsSync(pidFile())) return null;
48
- const raw = readFileSync(pidFile(), "utf8").trim();
49
- const n = Number(raw.split("\n")[0]);
50
- return Number.isInteger(n) && n > 0 ? n : null;
51
- }
52
-
53
- export function readPidVersion() {
54
- if (!existsSync(pidFile())) return null;
55
- const raw = readFileSync(pidFile(), "utf8");
56
- const lines = raw.split("\n");
57
- return lines.length > 1 && lines[1].trim() ? lines[1].trim() : null;
58
- }
59
-
60
- // Best-effort liveness check (signal 0); ESRCH means the process is gone.
61
- export function isPidAlive(pid) {
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch (err) {
66
- return err.code !== "ESRCH";
67
- }
68
- }
69
-
70
- export function stopDaemon() {
71
- const pid = readPid();
72
- if (!pid) return { stopped: false, reason: "no pid file" };
73
- try {
74
- process.kill(pid, "SIGTERM");
75
- } catch (err) {
76
- if (err.code !== "ESRCH") throw err;
77
- }
78
- try {
79
- unlinkSync(pidFile());
80
- } catch {
81
- // already gone
82
- }
83
- return { stopped: true, pid };
84
- }
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import os from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export function daemonDir() {
8
+ return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
9
+ }
10
+
11
+ export function pidFile() {
12
+ return join(daemonDir(), "daemon.pid");
13
+ }
14
+
15
+ export function logFile() {
16
+ return join(daemonDir(), "daemon.log");
17
+ }
18
+
19
+ export function startDaemon(args = []) {
20
+ const here = fileURLToPath(import.meta.url);
21
+ const entry = here.endsWith("bin/mslxdff.js")
22
+ ? here
23
+ : join(dirname(here), "..", "bin", "mslxdff.js");
24
+ const dir = daemonDir();
25
+ mkdirSync(dir, { recursive: true });
26
+ const logFd = openSync(logFile(), "a", 0o600);
27
+ const env = { ...process.env, MSLXDFF_DAEMON: "1" };
28
+ // a -debug foreground session wouldn't pass MSLXDFF_DEBUG to the
29
+ // background daemon it restores (that flag means "print events to stdout")
30
+ delete env.MSLXDFF_DEBUG;
31
+ const child = spawn(process.execPath, [entry, ...args, "--daemon"], {
32
+ detached: true,
33
+ stdio: ["ignore", logFd, logFd],
34
+ env,
35
+ });
36
+ child.unref();
37
+ return child.pid;
38
+ }
39
+
40
+ export function writePid(pid, version) {
41
+ const dir = daemonDir();
42
+ mkdirSync(dir, { recursive: true });
43
+ writeFileSync(pidFile(), version ? `${pid}\n${version}` : String(pid), { mode: 0o600 });
44
+ }
45
+
46
+ export function readPid() {
47
+ if (!existsSync(pidFile())) return null;
48
+ const raw = readFileSync(pidFile(), "utf8").trim();
49
+ const n = Number(raw.split("\n")[0]);
50
+ return Number.isInteger(n) && n > 0 ? n : null;
51
+ }
52
+
53
+ export function readPidVersion() {
54
+ if (!existsSync(pidFile())) return null;
55
+ const raw = readFileSync(pidFile(), "utf8");
56
+ const lines = raw.split("\n");
57
+ return lines.length > 1 && lines[1].trim() ? lines[1].trim() : null;
58
+ }
59
+
60
+ // Best-effort liveness check (signal 0); ESRCH means the process is gone.
61
+ export function isPidAlive(pid) {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (err) {
66
+ return err.code !== "ESRCH";
67
+ }
68
+ }
69
+
70
+ export function stopDaemon() {
71
+ const pid = readPid();
72
+ if (!pid) return { stopped: false, reason: "no pid file" };
73
+ try {
74
+ process.kill(pid, "SIGTERM");
75
+ } catch (err) {
76
+ if (err.code !== "ESRCH") throw err;
77
+ }
78
+ try {
79
+ unlinkSync(pidFile());
80
+ } catch {
81
+ // already gone
82
+ }
83
+ return { stopped: true, pid };
84
+ }
package/src/logs.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync, readFileSync, mkdirSync, existsSync, writeFileSync, statSync } from "node:fs";
2
+ import { appendFile, stat, readFile, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import os from "node:os";
4
5
  import { defaultStateFile } from "./state.js";
@@ -40,10 +41,48 @@ function trimIfOversized(file, maxBytes = MAX_BYTES) {
40
41
  }
41
42
  }
42
43
 
44
+ async function trimIfOversizedAsync(file, maxBytes = MAX_BYTES) {
45
+ try {
46
+ const st = await stat(file);
47
+ if (st.size <= maxBytes) return;
48
+ const text = await readFile(file, "utf8");
49
+ const lines = text.split("\n");
50
+ const keep = lines.slice(-100);
51
+ await writeFile(file, keep.join("\n"));
52
+ } catch {
53
+ // ignore
54
+ }
55
+ }
56
+
57
+ function shouldSync(file) {
58
+ if (process.env.MSLXDFF_LOGS_SYNC === "1") return true;
59
+ if (process.env.MSLXDFF_DAEMON_DIR) {
60
+ const dir = process.env.MSLXDFF_DAEMON_DIR;
61
+ if (file.startsWith(dir)) return true;
62
+ }
63
+ // 显式 tmp 文件(所有 test 的 mkdtemp 前缀)走同步,保证 read-after-write 可见
64
+ const low = file.toLowerCase();
65
+ if (low.includes("mslxdff-") || low.includes("tmp") || low.includes("temp")) return true;
66
+ // 非默认目录的文件一律同步(测试传入的临时路径)
67
+ try {
68
+ const def = logDir().toLowerCase();
69
+ if (!low.startsWith(def)) return true;
70
+ } catch {}
71
+ return false;
72
+ }
73
+
43
74
  function appendLine(file, entry) {
44
75
  ensureDir(dirname(file));
45
- appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n");
46
- trimIfOversized(file);
76
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
77
+ if (shouldSync(file)) {
78
+ appendFileSync(file, line);
79
+ trimIfOversized(file);
80
+ return;
81
+ }
82
+ // 线上异步:不阻塞事件循环
83
+ appendFile(file, line)
84
+ .catch(() => {})
85
+ .then(() => trimIfOversizedAsync(file).catch(() => {}));
47
86
  }
48
87
 
49
88
  export function appendCall(entry, { file = callsFile() } = {}) {