lark-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +162 -0
- package/README.md +161 -0
- package/dist/binder.d.ts +57 -0
- package/dist/binder.js +105 -0
- package/dist/bridge/index.d.ts +338 -0
- package/dist/bridge/index.js +1281 -0
- package/dist/bridge/queue-manager.d.ts +277 -0
- package/dist/bridge/queue-manager.js +729 -0
- package/dist/card/bash-card-session.d.ts +78 -0
- package/dist/card/bash-card-session.js +174 -0
- package/dist/card/bash-renderer.d.ts +24 -0
- package/dist/card/bash-renderer.js +187 -0
- package/dist/card/card-budget.d.ts +31 -0
- package/dist/card/card-budget.js +280 -0
- package/dist/card/card-session.d.ts +83 -0
- package/dist/card/card-session.js +185 -0
- package/dist/card/card-shared.d.ts +28 -0
- package/dist/card/card-shared.js +99 -0
- package/dist/card/collapsible.d.ts +26 -0
- package/dist/card/collapsible.js +87 -0
- package/dist/card/run-card-session.d.ts +78 -0
- package/dist/card/run-card-session.js +165 -0
- package/dist/card/run-renderer.d.ts +66 -0
- package/dist/card/run-renderer.js +841 -0
- package/dist/card/run-state.d.ts +103 -0
- package/dist/card/run-state.js +313 -0
- package/dist/card/text-truncate.d.ts +56 -0
- package/dist/card/text-truncate.js +184 -0
- package/dist/card/time.d.ts +1 -0
- package/dist/card/time.js +14 -0
- package/dist/card/tool-render.d.ts +25 -0
- package/dist/card/tool-render.js +176 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.js +89 -0
- package/dist/common/guards.d.ts +37 -0
- package/dist/common/guards.js +65 -0
- package/dist/config/codex-config.d.ts +113 -0
- package/dist/config/codex-config.js +428 -0
- package/dist/config/dir.d.ts +17 -0
- package/dist/config/dir.js +81 -0
- package/dist/config/index.d.ts +219 -0
- package/dist/config/index.js +388 -0
- package/dist/config/kimi-config.d.ts +33 -0
- package/dist/config/kimi-config.js +111 -0
- package/dist/config/opencode-config.d.ts +20 -0
- package/dist/config/opencode-config.js +124 -0
- package/dist/config/pi-config.d.ts +49 -0
- package/dist/config/pi-config.js +151 -0
- package/dist/config/wizard.d.ts +12 -0
- package/dist/config/wizard.js +84 -0
- package/dist/connector/file-limits.d.ts +7 -0
- package/dist/connector/file-limits.js +8 -0
- package/dist/connector/index.d.ts +86 -0
- package/dist/connector/index.js +340 -0
- package/dist/error-classification.d.ts +15 -0
- package/dist/error-classification.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +580 -0
- package/dist/instance-lock.d.ts +30 -0
- package/dist/instance-lock.js +184 -0
- package/dist/logger/index.d.ts +57 -0
- package/dist/logger/index.js +178 -0
- package/dist/order/index.d.ts +27 -0
- package/dist/order/index.js +90 -0
- package/dist/persistence/atomic-write.d.ts +10 -0
- package/dist/persistence/atomic-write.js +65 -0
- package/dist/persistence/load-json-file.d.ts +9 -0
- package/dist/persistence/load-json-file.js +23 -0
- package/dist/restart.d.ts +24 -0
- package/dist/restart.js +73 -0
- package/dist/router/card-helpers.d.ts +28 -0
- package/dist/router/card-helpers.js +65 -0
- package/dist/router/config/claude.d.ts +14 -0
- package/dist/router/config/claude.js +72 -0
- package/dist/router/config/codex.d.ts +22 -0
- package/dist/router/config/codex.js +100 -0
- package/dist/router/config/common/render.d.ts +16 -0
- package/dist/router/config/common/render.js +158 -0
- package/dist/router/config/index.d.ts +18 -0
- package/dist/router/config/index.js +38 -0
- package/dist/router/config/kimi.d.ts +21 -0
- package/dist/router/config/kimi.js +63 -0
- package/dist/router/config/opencode.d.ts +12 -0
- package/dist/router/config/opencode.js +44 -0
- package/dist/router/config/pi.d.ts +12 -0
- package/dist/router/config/pi.js +50 -0
- package/dist/router/config/types.d.ts +43 -0
- package/dist/router/config/types.js +8 -0
- package/dist/router/index.d.ts +318 -0
- package/dist/router/index.js +2715 -0
- package/dist/router/order-exec-dispatch.d.ts +30 -0
- package/dist/router/order-exec-dispatch.js +48 -0
- package/dist/router/utils.d.ts +44 -0
- package/dist/router/utils.js +116 -0
- package/dist/runner/bash/index.d.ts +4 -0
- package/dist/runner/bash/index.js +5 -0
- package/dist/runner/bash/runner.d.ts +55 -0
- package/dist/runner/bash/runner.js +213 -0
- package/dist/runner/claude/index.d.ts +4 -0
- package/dist/runner/claude/index.js +5 -0
- package/dist/runner/claude/runner.d.ts +29 -0
- package/dist/runner/claude/runner.js +83 -0
- package/dist/runner/codex/argv.d.ts +33 -0
- package/dist/runner/codex/argv.js +40 -0
- package/dist/runner/codex/index.d.ts +1 -0
- package/dist/runner/codex/index.js +2 -0
- package/dist/runner/codex/jsonl.d.ts +37 -0
- package/dist/runner/codex/jsonl.js +221 -0
- package/dist/runner/codex/runner.d.ts +65 -0
- package/dist/runner/codex/runner.js +124 -0
- package/dist/runner/common/exec-translator.d.ts +73 -0
- package/dist/runner/common/exec-translator.js +105 -0
- package/dist/runner/common/jsonl-stream.d.ts +39 -0
- package/dist/runner/common/jsonl-stream.js +203 -0
- package/dist/runner/common/process-stopper.d.ts +28 -0
- package/dist/runner/common/process-stopper.js +79 -0
- package/dist/runner/common/runner-utils.d.ts +21 -0
- package/dist/runner/common/runner-utils.js +65 -0
- package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
- package/dist/runner/common/spawn-heartbeat.js +51 -0
- package/dist/runner/common/spawning-runner.d.ts +321 -0
- package/dist/runner/common/spawning-runner.js +634 -0
- package/dist/runner/common/usage.d.ts +31 -0
- package/dist/runner/common/usage.js +29 -0
- package/dist/runner/index.d.ts +19 -0
- package/dist/runner/index.js +24 -0
- package/dist/runner/kimi/index.d.ts +1 -0
- package/dist/runner/kimi/index.js +2 -0
- package/dist/runner/kimi/jsonl.d.ts +28 -0
- package/dist/runner/kimi/jsonl.js +143 -0
- package/dist/runner/kimi/runner.d.ts +50 -0
- package/dist/runner/kimi/runner.js +105 -0
- package/dist/runner/opencode/argv.d.ts +32 -0
- package/dist/runner/opencode/argv.js +35 -0
- package/dist/runner/opencode/index.d.ts +1 -0
- package/dist/runner/opencode/index.js +2 -0
- package/dist/runner/opencode/jsonl.d.ts +68 -0
- package/dist/runner/opencode/jsonl.js +295 -0
- package/dist/runner/opencode/runner.d.ts +63 -0
- package/dist/runner/opencode/runner.js +93 -0
- package/dist/runner/pi/index.d.ts +1 -0
- package/dist/runner/pi/index.js +2 -0
- package/dist/runner/pi/jsonl.d.ts +128 -0
- package/dist/runner/pi/jsonl.js +249 -0
- package/dist/runner/pi/runner.d.ts +37 -0
- package/dist/runner/pi/runner.js +76 -0
- package/dist/runner/registry.d.ts +54 -0
- package/dist/runner/registry.js +75 -0
- package/dist/runner/resolve-agent-choices.d.ts +7 -0
- package/dist/runner/resolve-agent-choices.js +76 -0
- package/dist/runner/sync-agent-choices.d.ts +6 -0
- package/dist/runner/sync-agent-choices.js +73 -0
- package/dist/runner/types.d.ts +250 -0
- package/dist/runner/types.js +8 -0
- package/dist/session/claude/index.d.ts +1 -0
- package/dist/session/claude/index.js +2 -0
- package/dist/session/claude/session-reader.d.ts +34 -0
- package/dist/session/claude/session-reader.js +46 -0
- package/dist/session/claude/sessions.d.ts +53 -0
- package/dist/session/claude/sessions.js +528 -0
- package/dist/session/codex/index.d.ts +1 -0
- package/dist/session/codex/index.js +2 -0
- package/dist/session/codex/rollout-reader.d.ts +88 -0
- package/dist/session/codex/rollout-reader.js +473 -0
- package/dist/session/codex/sessions.d.ts +21 -0
- package/dist/session/codex/sessions.js +40 -0
- package/dist/session/common/catch-up.d.ts +12 -0
- package/dist/session/common/catch-up.js +31 -0
- package/dist/session/common/constants.d.ts +11 -0
- package/dist/session/common/constants.js +12 -0
- package/dist/session/common/content-blocks.d.ts +37 -0
- package/dist/session/common/content-blocks.js +68 -0
- package/dist/session/common/jsonl.d.ts +77 -0
- package/dist/session/common/jsonl.js +351 -0
- package/dist/session/common/usage-accumulator.d.ts +58 -0
- package/dist/session/common/usage-accumulator.js +72 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +11 -0
- package/dist/session/kimi/index.d.ts +1 -0
- package/dist/session/kimi/index.js +2 -0
- package/dist/session/kimi/sessions.d.ts +81 -0
- package/dist/session/kimi/sessions.js +527 -0
- package/dist/session/opencode/index.d.ts +1 -0
- package/dist/session/opencode/index.js +2 -0
- package/dist/session/opencode/sessions.d.ts +81 -0
- package/dist/session/opencode/sessions.js +388 -0
- package/dist/session/pi/index.d.ts +1 -0
- package/dist/session/pi/index.js +2 -0
- package/dist/session/pi/sessions.d.ts +27 -0
- package/dist/session/pi/sessions.js +399 -0
- package/dist/session/registry.d.ts +12 -0
- package/dist/session/registry.js +20 -0
- package/dist/session/session-store.d.ts +86 -0
- package/dist/session/session-store.js +450 -0
- package/dist/startup-contact.d.ts +18 -0
- package/dist/startup-contact.js +51 -0
- package/dist/workspace/index.d.ts +19 -0
- package/dist/workspace/index.js +53 -0
- package/package.json +93 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
export class InstanceAlreadyRunningError extends Error {
|
|
5
|
+
pid;
|
|
6
|
+
lockPath;
|
|
7
|
+
constructor(pid, lockPath) {
|
|
8
|
+
super(`lark-remote is already running for this config: pid ${pid}`);
|
|
9
|
+
this.pid = pid;
|
|
10
|
+
this.lockPath = lockPath;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Probe whether a pid is alive. P2-20:
|
|
15
|
+
* - process.kill(pid, 0) returning normally → alive.
|
|
16
|
+
* - ESRCH (no such process) → dead.
|
|
17
|
+
* - EPERM (operation not permitted) → the process EXISTS but is owned by
|
|
18
|
+
* another user; we just lack permission to signal it. Returning true here
|
|
19
|
+
* is correct: the single instance that owns the lock is still running, so
|
|
20
|
+
* we must refuse to acquire. Previously EPERM was swallowed as "not
|
|
21
|
+
* running", allowing a second instance to overwrite the lock.
|
|
22
|
+
* - EINVAL / other → conservative true (do not clobber a lock we cannot
|
|
23
|
+
* prove is stale).
|
|
24
|
+
*/
|
|
25
|
+
function isPidAlive(pid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const code = err.code;
|
|
32
|
+
if (code === 'ESRCH')
|
|
33
|
+
return false;
|
|
34
|
+
// EPERM and any unexpected error → assume alive (never clobber a lock
|
|
35
|
+
// we cannot disprove is held by a running instance).
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class InstanceLock {
|
|
40
|
+
lockPath;
|
|
41
|
+
/**
|
|
42
|
+
* P2-20: injectable process-running probe for testing, and a process-name
|
|
43
|
+
* verifier to defend against PID reuse (a stale pid recycled by an unrelated
|
|
44
|
+
* process would otherwise be misjudged as "our instance still running").
|
|
45
|
+
* Default implementation checks the process name via `ps` when available.
|
|
46
|
+
*/
|
|
47
|
+
isProcessRunning;
|
|
48
|
+
constructor(lockPath, opts) {
|
|
49
|
+
this.lockPath = lockPath;
|
|
50
|
+
this.isProcessRunning =
|
|
51
|
+
opts?.isProcessRunning ?? ((pid, expectedComm) => defaultIsProcessRunning(pid, expectedComm));
|
|
52
|
+
}
|
|
53
|
+
acquire() {
|
|
54
|
+
fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
|
|
55
|
+
// Atomic create-with-exclusive: 'wx' fails if file already exists.
|
|
56
|
+
// This eliminates the TOCTOU race between readPid+isProcessRunning
|
|
57
|
+
// and writeFileSync in the previous implementation.
|
|
58
|
+
try {
|
|
59
|
+
const fd = fs.openSync(this.lockPath, 'wx');
|
|
60
|
+
fs.writeSync(fd, this.lockContents(process.pid));
|
|
61
|
+
fs.closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err.code === 'EEXIST') {
|
|
65
|
+
// Lock file exists — check if the owner is still alive
|
|
66
|
+
const { pid, comm } = this.readPidAndComm();
|
|
67
|
+
if (pid && this.isProcessRunning(pid, comm)) {
|
|
68
|
+
throw new InstanceAlreadyRunningError(pid, this.lockPath);
|
|
69
|
+
}
|
|
70
|
+
// Stale lock: remove and retry once
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(this.lockPath);
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
try {
|
|
76
|
+
const fd = fs.openSync(this.lockPath, 'wx');
|
|
77
|
+
fs.writeSync(fd, this.lockContents(process.pid));
|
|
78
|
+
fs.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
catch (retryErr) {
|
|
81
|
+
if (retryErr.code === 'EEXIST') {
|
|
82
|
+
// Another process created the lock between unlink and retry.
|
|
83
|
+
// Bounded retry (max 1): give up rather than recurse.
|
|
84
|
+
const { pid, comm } = this.readPidAndComm();
|
|
85
|
+
if (pid && this.isProcessRunning(pid, comm)) {
|
|
86
|
+
throw new InstanceAlreadyRunningError(pid, this.lockPath);
|
|
87
|
+
}
|
|
88
|
+
// New lock is also stale — give up rather than recurse.
|
|
89
|
+
throw new InstanceAlreadyRunningError(pid ?? -1, this.lockPath);
|
|
90
|
+
}
|
|
91
|
+
throw retryErr;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
release() {
|
|
100
|
+
const { pid } = this.readPidAndComm();
|
|
101
|
+
if (pid === process.pid) {
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(this.lockPath);
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
registerExitHandlers() {
|
|
109
|
+
process.on('exit', () => this.release());
|
|
110
|
+
process.on('SIGINT', () => {
|
|
111
|
+
this.release();
|
|
112
|
+
process.exit(130);
|
|
113
|
+
});
|
|
114
|
+
process.on('SIGTERM', () => {
|
|
115
|
+
this.release();
|
|
116
|
+
process.exit(143);
|
|
117
|
+
});
|
|
118
|
+
if (process.platform !== 'win32') {
|
|
119
|
+
process.on('SIGHUP', () => {
|
|
120
|
+
this.release();
|
|
121
|
+
process.exit(129);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Lock file stores `pid\ncomm` where comm is the owning process's command
|
|
127
|
+
* name (captured at acquire time). On stale-lock checks we verify the
|
|
128
|
+
* recorded pid is still alive AND still running a process whose comm
|
|
129
|
+
* matches — defending against PID reuse where an unrelated process recycled
|
|
130
|
+
* the stale pid.
|
|
131
|
+
*/
|
|
132
|
+
lockContents(pid) {
|
|
133
|
+
return `${pid}\n${currentComm()}`;
|
|
134
|
+
}
|
|
135
|
+
readPidAndComm() {
|
|
136
|
+
if (!fs.existsSync(this.lockPath))
|
|
137
|
+
return { pid: undefined, comm: undefined };
|
|
138
|
+
const raw = fs.readFileSync(this.lockPath, 'utf-8').trim();
|
|
139
|
+
const [pidStr, ...commParts] = raw.split('\n');
|
|
140
|
+
const pid = Number(pidStr);
|
|
141
|
+
const comm = commParts.join('\n') || undefined;
|
|
142
|
+
if (Number.isInteger(pid) && pid > 0 && comm)
|
|
143
|
+
return { pid, comm };
|
|
144
|
+
// Malformed or missing comm — treat as corrupt, unlink and return undefined
|
|
145
|
+
try {
|
|
146
|
+
fs.unlinkSync(this.lockPath);
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
149
|
+
return { pid: undefined, comm: undefined };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Default probe: alive by ESRCH/EPERM semantics, plus a weak comm-name match
|
|
154
|
+
* to reject PID reuse. If `ps` is unavailable we fall back to the pid-alive
|
|
155
|
+
* check alone (no false negatives, only weaker reuse protection).
|
|
156
|
+
*/
|
|
157
|
+
function defaultIsProcessRunning(pid, expectedComm) {
|
|
158
|
+
if (!isPidAlive(pid))
|
|
159
|
+
return false;
|
|
160
|
+
if (!expectedComm)
|
|
161
|
+
return false;
|
|
162
|
+
const actualComm = readComm(pid);
|
|
163
|
+
if (actualComm === null)
|
|
164
|
+
return true; // ps unavailable → trust pid-alive
|
|
165
|
+
// Match on the basename so `/usr/local/bin/node` vs `node` still align.
|
|
166
|
+
const base = (s) => s.split('/').pop() ?? s;
|
|
167
|
+
return base(actualComm) === base(expectedComm) || actualComm.includes(expectedComm);
|
|
168
|
+
}
|
|
169
|
+
function currentComm() {
|
|
170
|
+
return readComm(process.pid) ?? 'lark-remote';
|
|
171
|
+
}
|
|
172
|
+
function readComm(pid) {
|
|
173
|
+
try {
|
|
174
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
|
175
|
+
encoding: 'utf-8',
|
|
176
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
177
|
+
}).trim();
|
|
178
|
+
return out || null;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=instance-lock.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
interface LoggerOptions {
|
|
3
|
+
/** Log directory. Defaults to ~/.lark-remote/logs */
|
|
4
|
+
dir?: string;
|
|
5
|
+
/** Minimum level to write. Defaults to 'info'. */
|
|
6
|
+
level?: LogLevel;
|
|
7
|
+
/** File prefix. Defaults to 'lark-remote'. */
|
|
8
|
+
prefix?: string;
|
|
9
|
+
/** Override pid (for testing). */
|
|
10
|
+
pid?: number;
|
|
11
|
+
/** Override now() (for testing). */
|
|
12
|
+
now?: () => Date;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* File-based logger with daily rotation.
|
|
16
|
+
*
|
|
17
|
+
* - Each day gets its own subdirectory: `<dir>/YYYY-MM-DD/`.
|
|
18
|
+
* - Log files are named `<prefix>-<pid>.log` inside the daily subdirectory.
|
|
19
|
+
* - When the calendar date changes (across midnight), a new directory and file
|
|
20
|
+
* are created automatically, so each file never spans two days.
|
|
21
|
+
* - Nothing is written to stdout/stderr; all output goes to the file.
|
|
22
|
+
* - Writes are synchronous (fs.appendFileSync) — log volume is low and this
|
|
23
|
+
* guarantees ordering without leaving dangling file handles on exit.
|
|
24
|
+
*/
|
|
25
|
+
export declare class Logger {
|
|
26
|
+
private dir;
|
|
27
|
+
private level;
|
|
28
|
+
private prefix;
|
|
29
|
+
private pid;
|
|
30
|
+
private now;
|
|
31
|
+
private currentDate;
|
|
32
|
+
private currentFile;
|
|
33
|
+
constructor(opts?: LoggerOptions);
|
|
34
|
+
/** Get the current log file path (e.g., ~/.lark-remote/logs/2026-07-11/lark-remote-12345.log). */
|
|
35
|
+
getCurrentLogFile(): string;
|
|
36
|
+
private ensureFile;
|
|
37
|
+
/**
|
|
38
|
+
* P2-19①: remove daily subdirectories older than the retention window.
|
|
39
|
+
* Best-effort — any error is swallowed (logging must never throw).
|
|
40
|
+
*/
|
|
41
|
+
private pruneOldDirs;
|
|
42
|
+
private write;
|
|
43
|
+
debug(...args: unknown[]): void;
|
|
44
|
+
info(...args: unknown[]): void;
|
|
45
|
+
warn(...args: unknown[]): void;
|
|
46
|
+
error(...args: unknown[]): void;
|
|
47
|
+
/** Reset internal file state so the next write rotates to a fresh daily file. */
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Initialize (or reinitialize) the global logger. Must be called once at
|
|
52
|
+
* startup, before any logging is expected to land in the right place.
|
|
53
|
+
* Returns the initialized logger.
|
|
54
|
+
*/
|
|
55
|
+
export declare function initLogger(opts: LoggerOptions): Logger;
|
|
56
|
+
export declare function getLogger(): Logger;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { format } from 'node:util';
|
|
5
|
+
const LEVEL_ORDER = {
|
|
6
|
+
debug: 10,
|
|
7
|
+
info: 20,
|
|
8
|
+
warn: 30,
|
|
9
|
+
error: 40,
|
|
10
|
+
};
|
|
11
|
+
const LEVEL_UPPER = {
|
|
12
|
+
debug: 'DEBUG',
|
|
13
|
+
info: 'INFO',
|
|
14
|
+
warn: 'WARN',
|
|
15
|
+
error: 'ERROR',
|
|
16
|
+
};
|
|
17
|
+
function todayStr(d) {
|
|
18
|
+
const y = d.getFullYear();
|
|
19
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
20
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
21
|
+
return `${y}-${m}-${day}`;
|
|
22
|
+
}
|
|
23
|
+
function timestampStr(d) {
|
|
24
|
+
// P2-19②: local ISO 8601 with milliseconds and explicit offset, e.g.
|
|
25
|
+
// 2026-08-03T01:30:00.000+08:00. Previously this used toISOString() (UTC)
|
|
26
|
+
// while the daily directory boundary (todayStr) used local time — after
|
|
27
|
+
// local midnight in a +08:00 zone the new day's directory held lines
|
|
28
|
+
// timestamped with the previous UTC day. Local offset keeps the timestamp's
|
|
29
|
+
// calendar date consistent with the directory date across all timezones.
|
|
30
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
31
|
+
const y = d.getFullYear();
|
|
32
|
+
const mo = pad(d.getMonth() + 1);
|
|
33
|
+
const da = pad(d.getDate());
|
|
34
|
+
const h = pad(d.getHours());
|
|
35
|
+
const mi = pad(d.getMinutes());
|
|
36
|
+
const s = pad(d.getSeconds());
|
|
37
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
38
|
+
const offsetMin = -d.getTimezoneOffset();
|
|
39
|
+
const sign = offsetMin >= 0 ? '+' : '-';
|
|
40
|
+
const absOff = Math.abs(offsetMin);
|
|
41
|
+
const offH = pad(Math.floor(absOff / 60));
|
|
42
|
+
const offM = pad(absOff % 60);
|
|
43
|
+
return `${y}-${mo}-${da}T${h}:${mi}:${s}.${ms}${sign}${offH}:${offM}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* File-based logger with daily rotation.
|
|
47
|
+
*
|
|
48
|
+
* - Each day gets its own subdirectory: `<dir>/YYYY-MM-DD/`.
|
|
49
|
+
* - Log files are named `<prefix>-<pid>.log` inside the daily subdirectory.
|
|
50
|
+
* - When the calendar date changes (across midnight), a new directory and file
|
|
51
|
+
* are created automatically, so each file never spans two days.
|
|
52
|
+
* - Nothing is written to stdout/stderr; all output goes to the file.
|
|
53
|
+
* - Writes are synchronous (fs.appendFileSync) — log volume is low and this
|
|
54
|
+
* guarantees ordering without leaving dangling file handles on exit.
|
|
55
|
+
*/
|
|
56
|
+
export class Logger {
|
|
57
|
+
dir;
|
|
58
|
+
level;
|
|
59
|
+
prefix;
|
|
60
|
+
pid;
|
|
61
|
+
now;
|
|
62
|
+
currentDate = '';
|
|
63
|
+
currentFile = '';
|
|
64
|
+
constructor(opts = {}) {
|
|
65
|
+
this.dir = opts.dir ?? path.join(os.homedir(), '.lark-remote', 'logs');
|
|
66
|
+
this.level = LEVEL_ORDER[opts.level ?? 'info'];
|
|
67
|
+
this.prefix = opts.prefix ?? 'lark-remote';
|
|
68
|
+
this.pid = opts.pid ?? process.pid;
|
|
69
|
+
this.now = opts.now ?? (() => new Date());
|
|
70
|
+
}
|
|
71
|
+
/** Get the current log file path (e.g., ~/.lark-remote/logs/2026-07-11/lark-remote-12345.log). */
|
|
72
|
+
getCurrentLogFile() {
|
|
73
|
+
// Ensure the file is up-to-date before returning
|
|
74
|
+
this.ensureFile(this.now());
|
|
75
|
+
return this.currentFile;
|
|
76
|
+
}
|
|
77
|
+
ensureFile(date) {
|
|
78
|
+
const dateStr = todayStr(date);
|
|
79
|
+
if (this.currentFile && this.currentDate === dateStr)
|
|
80
|
+
return this.currentFile;
|
|
81
|
+
// Date changed (or first call): rotate to a new daily subdirectory
|
|
82
|
+
const dayDir = path.join(this.dir, dateStr);
|
|
83
|
+
fs.mkdirSync(dayDir, { recursive: true });
|
|
84
|
+
this.currentFile = path.join(dayDir, `${this.prefix}-${this.pid}.log`);
|
|
85
|
+
this.currentDate = dateStr;
|
|
86
|
+
// P2-19①: prune daily log directories older than the retention window so
|
|
87
|
+
// the logs directory does not grow without bound (single-day size cap is
|
|
88
|
+
// out of scope; bounded retention by day is the requested guard). Wrapped
|
|
89
|
+
// defensively so cleanup failure never blocks logging.
|
|
90
|
+
this.pruneOldDirs(dateStr);
|
|
91
|
+
return this.currentFile;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* P2-19①: remove daily subdirectories older than the retention window.
|
|
95
|
+
* Best-effort — any error is swallowed (logging must never throw).
|
|
96
|
+
*/
|
|
97
|
+
pruneOldDirs(currentDateStr) {
|
|
98
|
+
const RETENTION_DAYS = 30;
|
|
99
|
+
try {
|
|
100
|
+
const entries = fs.readdirSync(this.dir, { withFileTypes: true });
|
|
101
|
+
const cutoff = new Date(`${currentDateStr}T00:00:00`).getTime() - RETENTION_DAYS * 86400000;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (!entry.isDirectory())
|
|
104
|
+
continue;
|
|
105
|
+
// Only prune directories that look like YYYY-MM-DD daily dirs.
|
|
106
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.name))
|
|
107
|
+
continue;
|
|
108
|
+
const t = new Date(`${entry.name}T00:00:00`).getTime();
|
|
109
|
+
if (Number.isNaN(t))
|
|
110
|
+
continue;
|
|
111
|
+
if (t < cutoff) {
|
|
112
|
+
fs.rmSync(path.join(this.dir, entry.name), { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* best-effort cleanup; never throw */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
write(level, args) {
|
|
121
|
+
if (LEVEL_ORDER[level] < this.level)
|
|
122
|
+
return;
|
|
123
|
+
const date = this.now();
|
|
124
|
+
const ts = timestampStr(date);
|
|
125
|
+
const message = format(...args);
|
|
126
|
+
const line = `${ts} [${LEVEL_UPPER[level]}] ${message}\n`;
|
|
127
|
+
const file = this.ensureFile(date);
|
|
128
|
+
// P2-19③: never let a disk error (EACCES / ENOSPC / EROFS) escape write().
|
|
129
|
+
// If logging itself throws — especially inside an uncaughtException handler
|
|
130
|
+
// that calls logger.error(...) — a re-throw would abort Node and skip
|
|
131
|
+
// instanceLock.release(), leaking the singleton lock. Logging must never
|
|
132
|
+
// kill the process. Fall back to a single stderr line for diagnosis.
|
|
133
|
+
try {
|
|
134
|
+
fs.appendFileSync(file, line);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
try {
|
|
138
|
+
process.stderr.write(`[logger] failed to write log: ${message}\n`);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* nothing left to do; never throw */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
debug(...args) {
|
|
146
|
+
this.write('debug', args);
|
|
147
|
+
}
|
|
148
|
+
info(...args) {
|
|
149
|
+
this.write('info', args);
|
|
150
|
+
}
|
|
151
|
+
warn(...args) {
|
|
152
|
+
this.write('warn', args);
|
|
153
|
+
}
|
|
154
|
+
error(...args) {
|
|
155
|
+
this.write('error', args);
|
|
156
|
+
}
|
|
157
|
+
/** Reset internal file state so the next write rotates to a fresh daily file. */
|
|
158
|
+
close() {
|
|
159
|
+
this.currentFile = '';
|
|
160
|
+
this.currentDate = '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// --- Singleton ---
|
|
164
|
+
let defaultLogger = new Logger();
|
|
165
|
+
/**
|
|
166
|
+
* Initialize (or reinitialize) the global logger. Must be called once at
|
|
167
|
+
* startup, before any logging is expected to land in the right place.
|
|
168
|
+
* Returns the initialized logger.
|
|
169
|
+
*/
|
|
170
|
+
export function initLogger(opts) {
|
|
171
|
+
defaultLogger.close();
|
|
172
|
+
defaultLogger = new Logger(opts);
|
|
173
|
+
return defaultLogger;
|
|
174
|
+
}
|
|
175
|
+
export function getLogger() {
|
|
176
|
+
return defaultLogger;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface OrderEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
text: string;
|
|
4
|
+
createdAt: string;
|
|
5
|
+
usedAt?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Global persistent order store.
|
|
9
|
+
*
|
|
10
|
+
* Atomic write (tmp + rename) and corrupt-file recovery,
|
|
11
|
+
* following the same pattern as WorkspaceStore.
|
|
12
|
+
*/
|
|
13
|
+
export declare class OrderStore {
|
|
14
|
+
private filePath;
|
|
15
|
+
private data;
|
|
16
|
+
constructor(filePath?: string);
|
|
17
|
+
/** Reload data from disk (useful when another process may have written) */
|
|
18
|
+
reload(): void;
|
|
19
|
+
private load;
|
|
20
|
+
private persist;
|
|
21
|
+
save(text: string): OrderEntry;
|
|
22
|
+
updateUsedAt(id: string): void;
|
|
23
|
+
get(): OrderEntry[];
|
|
24
|
+
has(id: string): boolean;
|
|
25
|
+
remove(id: string): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { atomicWriteJson } from '../persistence/atomic-write.js';
|
|
4
|
+
import { loadJsonFile } from '../persistence/load-json-file.js';
|
|
5
|
+
const MAX_TEXT_LENGTH = 200;
|
|
6
|
+
const MAX_ORDERS = 50;
|
|
7
|
+
/**
|
|
8
|
+
* Global persistent order store.
|
|
9
|
+
*
|
|
10
|
+
* Atomic write (tmp + rename) and corrupt-file recovery,
|
|
11
|
+
* following the same pattern as WorkspaceStore.
|
|
12
|
+
*/
|
|
13
|
+
export class OrderStore {
|
|
14
|
+
filePath;
|
|
15
|
+
data;
|
|
16
|
+
constructor(filePath) {
|
|
17
|
+
this.filePath = filePath ?? path.join(os.homedir(), '.lark-remote', 'orders.json');
|
|
18
|
+
this.data = [];
|
|
19
|
+
this.load();
|
|
20
|
+
}
|
|
21
|
+
/** Reload data from disk (useful when another process may have written) */
|
|
22
|
+
reload() {
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
25
|
+
load() {
|
|
26
|
+
const parsed = loadJsonFile(this.filePath, undefined);
|
|
27
|
+
if (!parsed) {
|
|
28
|
+
this.data = [];
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(parsed)) {
|
|
32
|
+
this.data = [];
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const valid = [];
|
|
36
|
+
for (const e of parsed) {
|
|
37
|
+
if (e &&
|
|
38
|
+
typeof e.id === 'string' &&
|
|
39
|
+
typeof e.text === 'string' &&
|
|
40
|
+
typeof e.createdAt === 'string') {
|
|
41
|
+
const entry = {
|
|
42
|
+
id: e.id,
|
|
43
|
+
text: e.text.slice(0, MAX_TEXT_LENGTH),
|
|
44
|
+
createdAt: e.createdAt,
|
|
45
|
+
};
|
|
46
|
+
if (typeof e.usedAt === 'string')
|
|
47
|
+
entry.usedAt = e.usedAt;
|
|
48
|
+
valid.push(entry);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
this.data = valid;
|
|
52
|
+
}
|
|
53
|
+
persist() {
|
|
54
|
+
atomicWriteJson(this.filePath, this.data);
|
|
55
|
+
}
|
|
56
|
+
save(text) {
|
|
57
|
+
if (text.length > MAX_TEXT_LENGTH) {
|
|
58
|
+
throw new Error(`指令文本超过 ${MAX_TEXT_LENGTH} 字符限制`);
|
|
59
|
+
}
|
|
60
|
+
if (this.data.length >= MAX_ORDERS) {
|
|
61
|
+
throw new Error(`指令数量已达上限 ${MAX_ORDERS}`);
|
|
62
|
+
}
|
|
63
|
+
const entry = {
|
|
64
|
+
id: crypto.randomUUID(),
|
|
65
|
+
text,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
this.data.push(entry);
|
|
69
|
+
this.persist();
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
updateUsedAt(id) {
|
|
73
|
+
const entry = this.data.find((e) => e.id === id);
|
|
74
|
+
if (entry) {
|
|
75
|
+
entry.usedAt = new Date().toISOString();
|
|
76
|
+
this.persist();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
get() {
|
|
80
|
+
return [...this.data];
|
|
81
|
+
}
|
|
82
|
+
has(id) {
|
|
83
|
+
return this.data.some((e) => e.id === id);
|
|
84
|
+
}
|
|
85
|
+
remove(id) {
|
|
86
|
+
this.data = this.data.filter((e) => e.id !== id);
|
|
87
|
+
this.persist();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomically write string content to a file via tmp + rename.
|
|
3
|
+
* Falls back to copy+unlink when rename fails with EXDEV (cross-device).
|
|
4
|
+
*/
|
|
5
|
+
export declare function atomicWrite(filePath: string, content: string, encoding?: BufferEncoding): void;
|
|
6
|
+
/**
|
|
7
|
+
* Atomically write JSON data to a file via tmp + rename.
|
|
8
|
+
* Ensures a crash mid-write cannot leave a truncated file.
|
|
9
|
+
*/
|
|
10
|
+
export declare function atomicWriteJson(filePath: string, data: unknown): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Atomically write string content to a file via tmp + rename.
|
|
5
|
+
* Falls back to copy+unlink when rename fails with EXDEV (cross-device).
|
|
6
|
+
*/
|
|
7
|
+
export function atomicWrite(filePath, content, encoding = 'utf-8') {
|
|
8
|
+
const dir = path.dirname(filePath);
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
10
|
+
const tmpPath = filePath + '.tmp';
|
|
11
|
+
fs.writeFileSync(tmpPath, content, encoding);
|
|
12
|
+
// fsync the tmp file before rename so its data is durable on disk;
|
|
13
|
+
// rename itself is only a metadata change, so without fsync a crash after
|
|
14
|
+
// rename could leave the target as a 0-byte file. Best-effort: some
|
|
15
|
+
// filesystems (tmpfs) may not support fsync — ignore those errors.
|
|
16
|
+
let fd;
|
|
17
|
+
try {
|
|
18
|
+
fd = fs.openSync(tmpPath, 'r');
|
|
19
|
+
fs.fsyncSync(fd);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* fsync unavailable on this fs — best-effort durability */
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (fd !== undefined) {
|
|
26
|
+
try {
|
|
27
|
+
fs.closeSync(fd);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* ignore close failure */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
fs.renameSync(tmpPath, filePath);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err.code === 'EXDEV') {
|
|
39
|
+
// Cross-device rename: copy then unlink (tmp cleanup handled by finally)
|
|
40
|
+
fs.copyFileSync(tmpPath, filePath);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
// Best-effort cleanup: on success the tmp was renamed away (ENOENT here,
|
|
48
|
+
// silently ignored); on EXDEV it was copied and now needs removal; on other
|
|
49
|
+
// errors we must not leak the tmp file.
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(tmpPath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* already moved or never created */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Atomically write JSON data to a file via tmp + rename.
|
|
60
|
+
* Ensures a crash mid-write cannot leave a truncated file.
|
|
61
|
+
*/
|
|
62
|
+
export function atomicWriteJson(filePath, data) {
|
|
63
|
+
atomicWrite(filePath, JSON.stringify(data, null, 2));
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=atomic-write.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load and parse a JSON file with fallback on corruption.
|
|
3
|
+
* Returns the parsed data or the fallback value if loading fails.
|
|
4
|
+
*
|
|
5
|
+
* @param filePath - Path to the JSON file
|
|
6
|
+
* @param fallback - Value to return if file doesn't exist or is corrupted
|
|
7
|
+
* @param parseFn - Optional custom parser (defaults to JSON.parse)
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadJsonFile<T>(filePath: string, fallback: T, parseFn?: (raw: string) => T): T;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getLogger } from '../logger/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Load and parse a JSON file with fallback on corruption.
|
|
5
|
+
* Returns the parsed data or the fallback value if loading fails.
|
|
6
|
+
*
|
|
7
|
+
* @param filePath - Path to the JSON file
|
|
8
|
+
* @param fallback - Value to return if file doesn't exist or is corrupted
|
|
9
|
+
* @param parseFn - Optional custom parser (defaults to JSON.parse)
|
|
10
|
+
*/
|
|
11
|
+
export function loadJsonFile(filePath, fallback, parseFn = JSON.parse) {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(filePath))
|
|
14
|
+
return fallback;
|
|
15
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
16
|
+
return parseFn(raw);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
getLogger().warn(`[persistence] failed to load ${filePath}, using fallback:`, err);
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=load-json-file.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-restart without an external watchdog/cron: the only process that can
|
|
3
|
+
* reliably start the successor is the dying bridge itself. The old process
|
|
4
|
+
* spawns a detached replacement (same executable + argv, hence same
|
|
5
|
+
* --config-dir), then exits cleanly. The child learns the old pid via env
|
|
6
|
+
* and waits for it to die before acquiring the instance lock, so the lock
|
|
7
|
+
* file stays the single source of truth for the handoff.
|
|
8
|
+
*/
|
|
9
|
+
/** Env var telling a replacement child which parent pid to wait for before acquiring the instance lock. */
|
|
10
|
+
export declare const RESTART_WAIT_PID_ENV = "LARK_REMOTE_RESTART_WAIT_PID";
|
|
11
|
+
/**
|
|
12
|
+
* Spawn a detached replacement bridge with the same executable and argv
|
|
13
|
+
* (including --config-dir) and return its pid. The caller is expected to
|
|
14
|
+
* exit right after. stdio is redirected to restart-child.log so early
|
|
15
|
+
* startup failures (before the file logger initializes) are not lost.
|
|
16
|
+
*/
|
|
17
|
+
export declare function spawnReplacementBridge(logsDir: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* On startup, if this process was spawned as a restart replacement, wait for
|
|
20
|
+
* the previous bridge process to exit (and release the instance lock) before
|
|
21
|
+
* continuing. No-op for normal starts. On timeout, proceed anyway — lock
|
|
22
|
+
* acquisition remains the authority.
|
|
23
|
+
*/
|
|
24
|
+
export declare function waitForPreviousInstance(): Promise<void>;
|