@syrkos/filaxis 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +46 -0
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +61 -0
- package/bin/filaxis.ts +112 -0
- package/dist/agent-toolkit.d.ts +99 -0
- package/dist/dialect.d.ts +31 -0
- package/dist/driver.d.ts +130 -0
- package/dist/events.d.ts +26 -0
- package/dist/factory.d.ts +16 -0
- package/dist/file-edit.d.ts +43 -0
- package/dist/file-transfer.d.ts +44 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4200 -0
- package/dist/layer-discovery.d.ts +30 -0
- package/dist/layer-env.d.ts +95 -0
- package/dist/local-driver.d.ts +47 -0
- package/dist/local-session-manager.d.ts +98 -0
- package/dist/logger.d.ts +12 -0
- package/dist/remote-session-manager.d.ts +64 -0
- package/dist/runtime-stack.d.ts +52 -0
- package/dist/session-buffer.d.ts +73 -0
- package/dist/session-types.d.ts +74 -0
- package/dist/shell-discovery.d.ts +4 -0
- package/dist/ssh-helpers.d.ts +88 -0
- package/dist/ssh-pty-driver.d.ts +74 -0
- package/dist/stream-session-manager.d.ts +83 -0
- package/dist/stream-window.d.ts +124 -0
- package/dist/terminal/command-executer.d.ts +73 -0
- package/dist/terminal/output-buffer.d.ts +49 -0
- package/dist/terminal/session-launcher.d.ts +28 -0
- package/dist/terminal/session-registry.d.ts +29 -0
- package/dist/terminal/tmux-invoker.d.ts +8 -0
- package/dist/terminal/types.d.ts +56 -0
- package/dist/terminal-manager.d.ts +30 -0
- package/dist/tmux-driver.d.ts +49 -0
- package/dist/vt100-screen.d.ts +46 -0
- package/docs/DEVELOPMENT.md +38 -0
- package/docs/KNOWN-ISSUES.md +35 -0
- package/docs/implementation-notes.md +57 -0
- package/docs/skills/cross-host-diagnosis.md +21 -0
- package/docs/skills/file-sync.md +22 -0
- package/docs/skills/longrun-observe.md +20 -0
- package/docs/skills/stream-window.md +28 -0
- package/docs//345/256/214/346/225/264/345/267/241/346/243/200/346/212/245/345/221/212.md +141 -0
- package/docs//346/265/213/350/257/2252.md +112 -0
- package/docs//346/265/213/350/257/2253.md +73 -0
- package/docs//346/265/213/350/257/2254.md +138 -0
- package/docs//346/265/213/350/257/2255.md +124 -0
- package/docs//347/254/254/344/272/214/344/273/275/345/267/241/346/243/200/346/212/245/345/221/212.md +103 -0
- package/examples/agent-tools.ts +74 -0
- package/examples/cross-host-diagnosis.ts +49 -0
- package/examples/longrun-observe.ts +46 -0
- package/package.json +47 -0
- package/src/agent-toolkit.ts +247 -0
- package/src/dialect.ts +100 -0
- package/src/driver.ts +142 -0
- package/src/events.ts +96 -0
- package/src/factory.ts +51 -0
- package/src/file-edit.ts +195 -0
- package/src/file-transfer.ts +196 -0
- package/src/index.ts +38 -0
- package/src/layer-discovery.ts +79 -0
- package/src/layer-env.ts +331 -0
- package/src/local-driver.ts +325 -0
- package/src/local-session-manager.ts +397 -0
- package/src/logger.ts +19 -0
- package/src/remote-session-manager.ts +286 -0
- package/src/runtime-stack.ts +128 -0
- package/src/session-buffer.ts +183 -0
- package/src/session-types.ts +78 -0
- package/src/shell-discovery.ts +52 -0
- package/src/ssh-helpers.ts +225 -0
- package/src/ssh-pty-driver.ts +655 -0
- package/src/stream-session-manager.ts +296 -0
- package/src/stream-window.ts +309 -0
- package/src/terminal/command-executer.ts +374 -0
- package/src/terminal/output-buffer.ts +126 -0
- package/src/terminal/session-launcher.ts +146 -0
- package/src/terminal/session-registry.ts +98 -0
- package/src/terminal/tmux-invoker.ts +65 -0
- package/src/terminal/types.ts +85 -0
- package/src/terminal-manager.ts +83 -0
- package/src/tmux-driver.ts +167 -0
- package/src/vt100-screen.ts +166 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/session-registry.ts — TerminalProfile 持久化与重连扫描
|
|
3
|
+
* (净室平移,源锚 8977ff3;logger 换注入)
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import { TmuxInvoker } from "./tmux-invoker.js";
|
|
7
|
+
import { noopLogger, type FilaxisLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
export interface TerminalProfile {
|
|
10
|
+
id: string;
|
|
11
|
+
driver: string;
|
|
12
|
+
target: string;
|
|
13
|
+
config?: Record<string, unknown>;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DirResolver = () => string;
|
|
18
|
+
|
|
19
|
+
export class SessionRegistry {
|
|
20
|
+
private reconciledDirs = new Set<string>();
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly dirResolver: DirResolver,
|
|
24
|
+
private readonly log: FilaxisLogger = noopLogger,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async ensureDir(): Promise<void> {
|
|
28
|
+
await fs.mkdir(this.dirResolver(), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 确保目录存在 + 每目录一次重连扫描(幂等;换目录=新一轮启动) */
|
|
32
|
+
async init(): Promise<void> {
|
|
33
|
+
await this.ensureDir();
|
|
34
|
+
const dir = this.dirResolver();
|
|
35
|
+
if (this.reconciledDirs.has(dir)) return;
|
|
36
|
+
this.reconciledDirs.add(dir);
|
|
37
|
+
await this.reconcileOnStartup();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async save(profile: TerminalProfile): Promise<void> {
|
|
41
|
+
await fs.writeFile(`${this.dirResolver()}/${profile.id}.json`, JSON.stringify(profile, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 缺失返回 null(不抛) */
|
|
45
|
+
async load(id: string): Promise<TerminalProfile | null> {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(`${this.dirResolver()}/${id}.json`, "utf-8");
|
|
48
|
+
return JSON.parse(raw) as TerminalProfile;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 幂等删除 */
|
|
55
|
+
async remove(id: string): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await fs.unlink(`${this.dirResolver()}/${id}.json`);
|
|
58
|
+
} catch { /* 幂等 */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 启动重连扫描:存活会话保留(=重连),僵尸 profile 清理 */
|
|
62
|
+
async reconcileOnStartup(): Promise<{ scanned: number; kept: number; removed: number }> {
|
|
63
|
+
const dir = this.dirResolver();
|
|
64
|
+
let files: string[];
|
|
65
|
+
try {
|
|
66
|
+
files = await fs.readdir(dir);
|
|
67
|
+
} catch {
|
|
68
|
+
return { scanned: 0, kept: 0, removed: 0 };
|
|
69
|
+
}
|
|
70
|
+
const profiles = files.filter((f) => f.endsWith(".json"));
|
|
71
|
+
let kept = 0;
|
|
72
|
+
let removed = 0;
|
|
73
|
+
await Promise.all(profiles.map(async (file) => {
|
|
74
|
+
const id = file.slice(0, -5);
|
|
75
|
+
const profile = await this.load(id);
|
|
76
|
+
if (!profile || profile.id !== id) {
|
|
77
|
+
await this.remove(id);
|
|
78
|
+
removed++;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
83
|
+
if (r.exitCode === 0) {
|
|
84
|
+
kept++;
|
|
85
|
+
this.log.info(`[reconcile] 会话 [${id}] 仍存活,保留(已重连)`);
|
|
86
|
+
} else {
|
|
87
|
+
await this.remove(id);
|
|
88
|
+
removed++;
|
|
89
|
+
this.log.warn(`[reconcile] 僵尸会话 [${id}] 已清理`);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// tmux 未安装:无法核实——保留 profile(保守,不误删)
|
|
93
|
+
kept++;
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
return { scanned: profiles.length, kept, removed };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/tmux-invoker.ts — tmux CLI 原子调用层(净室平移,源锚 8977ff3)
|
|
3
|
+
*
|
|
4
|
+
* stdout/stderr 严格分离;非零退出码 reject(带 exitCode+stderr)不吞错;
|
|
5
|
+
* exit 与 close 双监听先到为准(真实环境 exit 及时,mock 只发 close 兼容)。
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import type { TmuxResult } from "./types.js";
|
|
9
|
+
import { TmuxError } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export class TmuxInvoker {
|
|
12
|
+
static invoke(args: string[]): Promise<TmuxResult> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
let settled = false;
|
|
17
|
+
const p = spawn("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
18
|
+
p.stdout.on("data", (d: Buffer) => { stdout += d; });
|
|
19
|
+
p.stderr.on("data", (d: Buffer) => { stderr += d; });
|
|
20
|
+
const settle = (exitCode: number | null) => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
const code = exitCode ?? 0;
|
|
24
|
+
if (code === 0) {
|
|
25
|
+
resolve({ stdout, stderr, exitCode: 0 });
|
|
26
|
+
} else {
|
|
27
|
+
reject(new TmuxError(
|
|
28
|
+
`tmux ${args.join(" ")} failed with exit code ${code}` +
|
|
29
|
+
(stderr ? `: ${stderr.trim()}` : ""),
|
|
30
|
+
args, code, stderr,
|
|
31
|
+
));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
p.once("exit", (code) => settle(code));
|
|
35
|
+
p.once("close", (code) => settle(code));
|
|
36
|
+
p.once("error", (err) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
reject(err);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 软失败变体:非零退出码返回带 exitCode 的结果(has-session 的 1=不存在是合法语义) */
|
|
45
|
+
static async tryInvoke(args: string[]): Promise<TmuxResult> {
|
|
46
|
+
try {
|
|
47
|
+
return await this.invoke(args);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e instanceof TmuxError) {
|
|
50
|
+
return { stdout: "", stderr: e.stderr, exitCode: e.exitCode };
|
|
51
|
+
}
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** tmux 是否可用(spawn 失败=未安装) */
|
|
57
|
+
static async isAvailable(): Promise<boolean> {
|
|
58
|
+
try {
|
|
59
|
+
await this.tryInvoke(["list-sessions"]);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/types.ts — 持久会话子系统共享类型与默认配置
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/terminal/{types,config}.ts`
|
|
5
|
+
* (源锚 8977ff3)。OSS 化:ConfigLoader/Zod schema 依赖剥——TerminalConfig
|
|
6
|
+
* 变为注入式选项 + 本文件默认值(内核不绑任何配置装载器)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** tmux CLI 单次调用结果(stdout/stderr 严格分离;非零退出码 reject 不吞错) */
|
|
10
|
+
export interface TmuxResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TmuxError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
public readonly args: string[],
|
|
20
|
+
public readonly exitCode: number,
|
|
21
|
+
public readonly stderr: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "TmuxError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 交互 Prompt 载荷(持久会话内检测到 Password/Confirm 等等待输入时) */
|
|
29
|
+
export interface PromptPayload {
|
|
30
|
+
type: "CONFIRM";
|
|
31
|
+
title: string;
|
|
32
|
+
message: string;
|
|
33
|
+
metadata: { terminalId: string; prompt: string; platform: "terminal" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 交互响应者:返回用户回复字符串,null 表示未作答(消费方自行适配 UI/IM) */
|
|
37
|
+
export interface PromptResponder {
|
|
38
|
+
ask(payload: PromptPayload): Promise<string | null>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type OutputStream = "stdout" | "stderr";
|
|
42
|
+
|
|
43
|
+
/** 持久会话子系统配置(注入式;默认值覆盖源 ConfigLoader schema 语义) */
|
|
44
|
+
export interface TerminalConfig {
|
|
45
|
+
/** 前置环境加固前缀(关交互入口——GIT_TERMINAL_PROMPT/SSH_ASKPASS/PAGER) */
|
|
46
|
+
env_hardening_prefix: string;
|
|
47
|
+
spawn_max_wait_ms_local: number;
|
|
48
|
+
spawn_max_wait_ms_remote: number;
|
|
49
|
+
spawn_poll_ms: number;
|
|
50
|
+
command_timeout_ms: number;
|
|
51
|
+
/** 不活动超时(只杀哑不杀慢——有新输出即重置) */
|
|
52
|
+
no_output_timeout_ms: number;
|
|
53
|
+
poll_interval_ms: number;
|
|
54
|
+
capture_scrollback: number;
|
|
55
|
+
buffer_pending_chars: number;
|
|
56
|
+
buffer_aggregated_chars: number;
|
|
57
|
+
buffer_tail_chars: number;
|
|
58
|
+
/** 交互 Prompt 检测模式(多行正则,末尾锚定由 compile 保证) */
|
|
59
|
+
prompt_patterns: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_TERMINAL_CONFIG: TerminalConfig = {
|
|
63
|
+
env_hardening_prefix: "export GIT_TERMINAL_PROMPT=0 SSH_ASKPASS=/bin/false PAGER=cat",
|
|
64
|
+
spawn_max_wait_ms_local: 5_000,
|
|
65
|
+
spawn_max_wait_ms_remote: 20_000,
|
|
66
|
+
spawn_poll_ms: 200,
|
|
67
|
+
command_timeout_ms: 600_000,
|
|
68
|
+
no_output_timeout_ms: 120_000,
|
|
69
|
+
poll_interval_ms: 500,
|
|
70
|
+
capture_scrollback: 500,
|
|
71
|
+
buffer_pending_chars: 100_000,
|
|
72
|
+
buffer_aggregated_chars: 500_000,
|
|
73
|
+
buffer_tail_chars: 4_000,
|
|
74
|
+
prompt_patterns: [
|
|
75
|
+
"(?:password|passphrase|密码|口令)[^\\n]*[::]\\s*$",
|
|
76
|
+
"\\(yes\\/no[^)]*\\)\\s*\\??\\s*$",
|
|
77
|
+
"press any key to continue[^\\n]*$",
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** 把 prompt_patterns 源串编译成多行 OR 正则(末尾锚定 $;mi——大小写不敏感+多行) */
|
|
82
|
+
export function compilePromptPatterns(patterns: string[]): RegExp {
|
|
83
|
+
const joined = patterns.length === 1 ? patterns[0]! : `(?:${patterns.join("|")})`;
|
|
84
|
+
return new RegExp(joined, "mi");
|
|
85
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-manager.ts — 持久终端会话门面(净室平移,源锚 8977ff3)
|
|
3
|
+
*
|
|
4
|
+
* OSS 化:源版全 static + getProjectRoot 单例 → 实例类(terminalDir 可配,
|
|
5
|
+
* 缺省 os.tmpdir()/filaxis-terminals;多实例多目录并存);logger 注入。
|
|
6
|
+
*
|
|
7
|
+
* 公开契约(与源版签名一致):
|
|
8
|
+
* spawnSession(driver,target,config?,customId?) → Promise<string>
|
|
9
|
+
* runCommand(id,cmd,timeoutMs|options) → Promise<{stdout,exitCode}>
|
|
10
|
+
* getSession(id) / destroySession(id) / init()
|
|
11
|
+
*/
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { SessionRegistry, type TerminalProfile } from "./terminal/session-registry.js";
|
|
15
|
+
import { SessionLauncher } from "./terminal/session-launcher.js";
|
|
16
|
+
import { CommandExecuter, type RunOptions } from "./terminal/command-executer.js";
|
|
17
|
+
import { TmuxInvoker } from "./terminal/tmux-invoker.js";
|
|
18
|
+
import type { TerminalConfig } from "./terminal/types.js";
|
|
19
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
20
|
+
|
|
21
|
+
export type { TerminalProfile } from "./terminal/session-registry.js";
|
|
22
|
+
|
|
23
|
+
export interface TerminalManagerOptions {
|
|
24
|
+
/** profile 持久化目录(缺省 os.tmpdir()/filaxis-terminals) */
|
|
25
|
+
terminalDir?: string;
|
|
26
|
+
config?: Partial<TerminalConfig>;
|
|
27
|
+
logger?: FilaxisLogger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TerminalManager {
|
|
31
|
+
private readonly registry: SessionRegistry;
|
|
32
|
+
private readonly launcher: SessionLauncher;
|
|
33
|
+
readonly terminalDir: string;
|
|
34
|
+
|
|
35
|
+
constructor(opts: TerminalManagerOptions = {}) {
|
|
36
|
+
this.terminalDir = opts.terminalDir ?? path.join(tmpdir(), "filaxis-terminals");
|
|
37
|
+
const log = opts.logger ?? noopLogger;
|
|
38
|
+
this.registry = new SessionRegistry(() => this.terminalDir, log);
|
|
39
|
+
this.launcher = new SessionLauncher(this.registry, { config: opts.config, logger: log });
|
|
40
|
+
CommandExecuter.configure({ config: opts.config, logger: log });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 确保目录存在 + 一次重连扫描(幂等) */
|
|
44
|
+
async init(): Promise<void> {
|
|
45
|
+
await this.registry.init();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 启动(或复用)持久 tmux 会话 */
|
|
49
|
+
async spawnSession(
|
|
50
|
+
driver: string,
|
|
51
|
+
target: string | undefined,
|
|
52
|
+
config?: Record<string, unknown>,
|
|
53
|
+
customId?: string,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
await this.init();
|
|
56
|
+
return this.launcher.launch(driver, target, config, customId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 会话内执行命令(纯净 stdout + exitCode;第三参 number|RunOptions 多态) */
|
|
60
|
+
async runCommand(
|
|
61
|
+
id: string,
|
|
62
|
+
command: string,
|
|
63
|
+
timeoutMsOrOptions: number | RunOptions = 600_000,
|
|
64
|
+
): Promise<{ stdout: string; exitCode: number }> {
|
|
65
|
+
const opts: RunOptions = typeof timeoutMsOrOptions === "number"
|
|
66
|
+
? { timeoutMs: timeoutMsOrOptions }
|
|
67
|
+
: timeoutMsOrOptions;
|
|
68
|
+
return CommandExecuter.execute(id, command, opts);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 销毁会话 + 删 profile(best-effort:kill-session 失败不阻断) */
|
|
72
|
+
async destroySession(id: string): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
await TmuxInvoker.invoke(["kill-session", "-t", id]);
|
|
75
|
+
} catch { /* best-effort */ }
|
|
76
|
+
await this.registry.remove(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 读 profile;缺失返回 null */
|
|
80
|
+
async getSession(id: string): Promise<TerminalProfile | null> {
|
|
81
|
+
return this.registry.load(id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-driver.ts — 持久会话 Transport 适配层
|
|
3
|
+
* (净室平移自 Aethelum-Nexus tmux-driver.ts,源锚 8977ff3)
|
|
4
|
+
*
|
|
5
|
+
* OSS 化:剥 RuntimeProcessManager/RemoteSessionManager/TmuxFileSystem——
|
|
6
|
+
* fs 经 runCommand base64(方言适配接线 M2 批);远端会话族沿用本文件
|
|
7
|
+
* nohup 会话语义(经 TerminalManager 会话 shell 执行)。
|
|
8
|
+
* terminalId 绑定 + cwd 恢复 + destroy 时 nohup 后台进程显式清理。
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
RuntimeDriver, FileSystemDriver, FileEntry, ExecOptions, ExecResult, RuntimeDescriptor,
|
|
12
|
+
} from "./driver.js";
|
|
13
|
+
import { TerminalManager } from "./terminal-manager.js";
|
|
14
|
+
import type {
|
|
15
|
+
SessionStartOptions, SessionHandleInfo, WatchOptions, ReadLogOptions,
|
|
16
|
+
} from "./session-types.js";
|
|
17
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
18
|
+
|
|
19
|
+
const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
20
|
+
|
|
21
|
+
export interface TmuxDriverOptions {
|
|
22
|
+
terminalManager?: TerminalManager;
|
|
23
|
+
logger?: FilaxisLogger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TmuxSessionDriver implements RuntimeDriver {
|
|
27
|
+
descriptor: RuntimeDescriptor;
|
|
28
|
+
fs: FileSystemDriver;
|
|
29
|
+
readonly terminal: TerminalManager;
|
|
30
|
+
private readonly log: FilaxisLogger;
|
|
31
|
+
|
|
32
|
+
constructor(descriptor: RuntimeDescriptor, opts: TmuxDriverOptions = {}) {
|
|
33
|
+
this.descriptor = descriptor;
|
|
34
|
+
this.terminal = opts.terminalManager ?? new TerminalManager();
|
|
35
|
+
this.log = opts.logger ?? noopLogger;
|
|
36
|
+
const self = this;
|
|
37
|
+
this.fs = {
|
|
38
|
+
async readFile(p) { return (await self.exec(`base64 ${q(p)} | base64 -d 2>/dev/null`)).stdout; },
|
|
39
|
+
async writeFile(p, content) {
|
|
40
|
+
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
|
41
|
+
await self.exec(`printf %s ${q(b64)} | base64 -d > ${q(p)}`);
|
|
42
|
+
},
|
|
43
|
+
async exists(p) {
|
|
44
|
+
const r = await self.exec(`test -e ${q(p)} && echo Y || echo N`);
|
|
45
|
+
return r.stdout.trim() === "Y";
|
|
46
|
+
},
|
|
47
|
+
async stat(p) {
|
|
48
|
+
const r = await self.exec(`stat -c '%s %Y' ${q(p)} 2>/dev/null || stat -f '%z %m' ${q(p)} 2>/dev/null || true`);
|
|
49
|
+
const m = r.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
50
|
+
return m ? { size: parseInt(m[1]!, 10), mtimeMs: parseInt(m[2]!, 10) * 1000 } : null;
|
|
51
|
+
},
|
|
52
|
+
async readdir(p): Promise<FileEntry[]> {
|
|
53
|
+
const r = await self.exec(`ls -1A ${q(p)} 2>/dev/null || true`);
|
|
54
|
+
const names = r.stdout.split("\n").filter(Boolean);
|
|
55
|
+
const out: FileEntry[] = [];
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const full = `${p.replace(/\/$/, "")}/${name}`;
|
|
58
|
+
const d = await self.exec(`test -d ${q(full)} && echo Y || echo N`);
|
|
59
|
+
const st = await (this as FileSystemDriver).stat!(full);
|
|
60
|
+
out.push({ name, isDirectory: d.stdout.trim() === "Y", size: st?.size, mtime: st?.mtimeMs });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
async rm(p, options) { await self.exec(`rm ${options?.recursive ? "-r " : ""}${options?.force ? "-f " : ""}${q(p)}`); },
|
|
65
|
+
async mkdir(p, options) { await self.exec(`mkdir ${options?.recursive ? "-p " : ""}${q(p)}`); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** init:无 terminalId 则 spawn;有则验证存活(跨进程重连语义);cwd 恢复 */
|
|
70
|
+
async init(): Promise<void> {
|
|
71
|
+
if (!this.descriptor.terminalId) {
|
|
72
|
+
this.descriptor.terminalId = await this.terminal.spawnSession(
|
|
73
|
+
this.descriptor.driver,
|
|
74
|
+
this.descriptor.target,
|
|
75
|
+
this.descriptor.config,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (this.descriptor.cwd) {
|
|
79
|
+
try {
|
|
80
|
+
await this.terminal.runCommand(
|
|
81
|
+
this.descriptor.terminalId,
|
|
82
|
+
`cd "${this.descriptor.cwd}" 2>/dev/null || true`,
|
|
83
|
+
10_000,
|
|
84
|
+
);
|
|
85
|
+
} catch { /* cwd 恢复失败不阻断初始化 */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 本实例启动的后台会话(sessionId 集合——destroy 只清理自己的,绝不扫目录误杀他层) */
|
|
90
|
+
private ownedSessions = new Set<string>();
|
|
91
|
+
|
|
92
|
+
/** destroy:只停**本实例**启动的后台进程(审查问题 1:全目录扫杀会误杀同机其他
|
|
93
|
+
* Filaxis 实例/其他层的任务——/tmp/filaxis-sessions 是共享目录),再销毁会话 */
|
|
94
|
+
async destroy(): Promise<void> {
|
|
95
|
+
if (this.descriptor.terminalId) {
|
|
96
|
+
for (const sid of this.ownedSessions) {
|
|
97
|
+
const base = `/tmp/filaxis-sessions/${sid}`;
|
|
98
|
+
try {
|
|
99
|
+
await this.terminal.runCommand(
|
|
100
|
+
this.descriptor.terminalId,
|
|
101
|
+
`kill $(cat ${q(base)}.pid 2>/dev/null) 2>/dev/null; rm -f ${q(base)}.pid; true`,
|
|
102
|
+
5_000,
|
|
103
|
+
);
|
|
104
|
+
} catch { /* best-effort:单个会话清理失败不阻断其余 */ }
|
|
105
|
+
}
|
|
106
|
+
this.ownedSessions.clear();
|
|
107
|
+
await this.terminal.destroySession(this.descriptor.terminalId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
|
|
112
|
+
if (options?.channel === "pty") {
|
|
113
|
+
// tmux 后端本身即 PTY 语义(send-keys/capture-pane)
|
|
114
|
+
const r = await this.terminal.runCommand(this.descriptor.terminalId!, command, {
|
|
115
|
+
timeoutMs: options?.timeoutMs ?? 120_000,
|
|
116
|
+
});
|
|
117
|
+
return { stdout: r.stdout, stderr: "", exitCode: r.exitCode };
|
|
118
|
+
}
|
|
119
|
+
// exec 通道语义:独立 ssh 直达不适用于 tmux 复合层——走 PTY 但显式声明混流
|
|
120
|
+
const r = await this.terminal.runCommand(this.descriptor.terminalId!, command, {
|
|
121
|
+
timeoutMs: options?.timeoutMs ?? 120_000,
|
|
122
|
+
});
|
|
123
|
+
return { stdout: r.stdout, stderr: "", exitCode: r.exitCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getCwd(): Promise<string> {
|
|
127
|
+
const r = await this.exec("pwd");
|
|
128
|
+
return r.stdout.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getEnv(key: string): Promise<string | undefined> {
|
|
132
|
+
return (await this.exec(`printf %s "\\$${key}"`)).stdout;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 会话族:nohup 后台进程经会话 shell 启动(.pid 落盘供 destroy 清理)──
|
|
136
|
+
async execSession(command: string, opts?: SessionStartOptions): Promise<SessionHandleInfo> {
|
|
137
|
+
const sessionId = `${opts?.name ?? "tsess"}-${Math.random().toString(16).slice(2, 8)}`;
|
|
138
|
+
const base = `/tmp/filaxis-sessions/${sessionId}`;
|
|
139
|
+
const wrapped = `{ ${command}; }; echo $? > ${base}.code`;
|
|
140
|
+
const cd = opts?.cwd ? `cd ${q(opts.cwd)} && ` : "";
|
|
141
|
+
await this.exec(`mkdir -p /tmp/filaxis-sessions; ${cd}nohup bash -c ${q(wrapped)} > ${base}.out 2> ${base}.err < /dev/null & echo $! > ${base}.pid; echo started`);
|
|
142
|
+
this.ownedSessions.add(sessionId);
|
|
143
|
+
return { sessionId, logBase: base };
|
|
144
|
+
}
|
|
145
|
+
async tailSession(sessionId: string, lines = 50): Promise<string[]> {
|
|
146
|
+
const r = await this.exec(`tail -n ${lines} /tmp/filaxis-sessions/${sessionId}.out 2>/dev/null || true`);
|
|
147
|
+
return r.stdout.split("\n").filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
async readSessionLog(sessionId: string, opts?: ReadLogOptions): Promise<string> {
|
|
150
|
+
const suffix = opts?.stream === "stderr" ? ".err" : ".out";
|
|
151
|
+
let cmd = `cat /tmp/filaxis-sessions/${sessionId}${suffix} 2>/dev/null || true`;
|
|
152
|
+
if (opts?.lines) cmd += ` | tail -n ${opts.lines}`;
|
|
153
|
+
return (await this.exec(cmd)).stdout.replace(/\n$/, "");
|
|
154
|
+
}
|
|
155
|
+
watchSession(_sessionId: string, _opts: WatchOptions): { cancel(): void } {
|
|
156
|
+
throw new Error("tmux 后端会话 watcher 属 M2 观察流批次(stream-session 落位后接入)");
|
|
157
|
+
}
|
|
158
|
+
async stopSession(sessionId: string): Promise<{ exitCode?: number | null }> {
|
|
159
|
+
await this.exec(`kill $(cat /tmp/filaxis-sessions/${sessionId}.pid 2>/dev/null) 2>/dev/null; true`);
|
|
160
|
+
const code = await this.exec(`cat /tmp/filaxis-sessions/${sessionId}.code 2>/dev/null || true`);
|
|
161
|
+
const c = code.stdout.trim();
|
|
162
|
+
return { exitCode: /^\d+$/.test(c) ? parseInt(c, 10) : null };
|
|
163
|
+
}
|
|
164
|
+
listSessions(): Array<{ sessionId: string; command: string; running: boolean; startedAt: number }> {
|
|
165
|
+
return []; // 无常驻索引(M2 批补 registry 化)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vt100-screen.ts — 轻量 VT100 虚拟屏幕(TUI 全屏程序字符级截屏)
|
|
3
|
+
*
|
|
4
|
+
* 第五季观察窗专项实测灵感:`watch/htop/nvtop` 等 ncurses 程序按
|
|
5
|
+
* `\x1b[row;colH` 光标定位增量重绘、**不输出换行**——行模式对它退化(数据在但
|
|
6
|
+
* 不可读)。本件是可选的终端仿真:feed 原始字节流 → snapshot 字符级高清截屏。
|
|
7
|
+
*
|
|
8
|
+
* 纯逻辑零 I/O 零依赖;手写状态机零正则。转义序列子集(观察够用):
|
|
9
|
+
* CUP/HVP(光标定位)、ED/EL(清屏/清行)、CUU/CUD/CUF/CUB(光标移动)、
|
|
10
|
+
* LF(换行+滚屏)/CR/BS/TAB、SGR 忽略、OSC 忽略;其余序列保守忽略。
|
|
11
|
+
* Autowrap:超行宽折到下一行(VT100 语义——ncurses 假设终端宽=实际宽,
|
|
12
|
+
* 建议 cols 给足)。
|
|
13
|
+
*/
|
|
14
|
+
export interface Vt100ScreenOptions {
|
|
15
|
+
rows?: number;
|
|
16
|
+
cols?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ESC = "\u001b";
|
|
20
|
+
const BEL = "\u0007";
|
|
21
|
+
|
|
22
|
+
export class Vt100Screen {
|
|
23
|
+
private rows: string[][];
|
|
24
|
+
private curRow = 0;
|
|
25
|
+
private curCol = 0;
|
|
26
|
+
/** 解析状态:0=normal 1=esc 2=csi 3=osc */
|
|
27
|
+
private state = 0;
|
|
28
|
+
private csiBuf = "";
|
|
29
|
+
private oscBuf = "";
|
|
30
|
+
private _frames = 0;
|
|
31
|
+
|
|
32
|
+
constructor(opts: Vt100ScreenOptions = {}) {
|
|
33
|
+
const rows = opts.rows ?? 24;
|
|
34
|
+
const cols = opts.cols ?? 220;
|
|
35
|
+
this.rows = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 喂原始字节流(含转义序列) */
|
|
39
|
+
feed(chunk: string): void {
|
|
40
|
+
for (const ch of chunk) this.put(ch);
|
|
41
|
+
this._frames++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 字符级高清截屏(行尾空白裁剪) */
|
|
45
|
+
snapshot(): string {
|
|
46
|
+
return this.rows.map((r) => r.join("").replace(/ +$/, "")).join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get cursor(): { row: number; col: number } {
|
|
50
|
+
return { row: this.curRow, col: this.curCol };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 已喂 chunk 数(帧计数——TUI 增量包一包一帧) */
|
|
54
|
+
get frames(): number {
|
|
55
|
+
return this._frames;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private put(ch: string): void {
|
|
59
|
+
switch (this.state) {
|
|
60
|
+
case 0: { // normal
|
|
61
|
+
if (ch === ESC) { this.state = 1; return; }
|
|
62
|
+
this.writeChar(ch);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 1: { // esc
|
|
66
|
+
if (ch === "[") { this.state = 2; this.csiBuf = ""; return; }
|
|
67
|
+
if (ch === "]") { this.state = 3; this.oscBuf = ""; return; }
|
|
68
|
+
// 其余两字符序列((B 等)忽略
|
|
69
|
+
this.state = 0;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case 2: { // csi
|
|
73
|
+
if (ch >= "0" && ch <= "9" || ch === ";" || ch === "?") { this.csiBuf += ch; return; }
|
|
74
|
+
this.state = 0;
|
|
75
|
+
this.csiDispatch(ch);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
default: { // osc
|
|
79
|
+
if (ch === BEL || ch === ESC) { this.state = 0; return; }
|
|
80
|
+
if (this.oscBuf.length < 256) this.oscBuf += ch; // 忽略内容(防长流)
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private csiDispatch(final: string): void {
|
|
87
|
+
const parts = this.csiBuf.replace(/\?/g, "").split(";");
|
|
88
|
+
const n = (i: number, d: number) => {
|
|
89
|
+
const v = parts[i] === undefined || parts[i] === "" ? d : parseInt(parts[i]!, 10);
|
|
90
|
+
return Number.isFinite(v) ? v : d;
|
|
91
|
+
};
|
|
92
|
+
switch (final) {
|
|
93
|
+
case "H": case "f": // CUP/HVP:1-based 定位
|
|
94
|
+
this.moveTo(Math.max(0, n(0, 1) - 1), Math.max(0, n(1, 1) - 1));
|
|
95
|
+
return;
|
|
96
|
+
case "A": this.moveTo(this.curRow - Math.max(1, n(0, 1)), this.curCol); return; // CUU
|
|
97
|
+
case "B": this.moveTo(this.curRow + Math.max(1, n(0, 1)), this.curCol); return; // CUD
|
|
98
|
+
case "C": this.moveTo(this.curRow, this.curCol + Math.max(1, n(0, 1))); return; // CUF
|
|
99
|
+
case "D": this.moveTo(this.curRow, this.curCol - Math.max(1, n(0, 1))); return; // CUB
|
|
100
|
+
case "J": { // ED
|
|
101
|
+
const mode = n(0, 0);
|
|
102
|
+
if (mode === 2) this.clearAll();
|
|
103
|
+
else if (mode === 0) this.clearRegion(this.curRow, this.curCol, this.rows.length - 1, -1);
|
|
104
|
+
else if (mode === 1) this.clearRegion(0, 0, this.curRow, this.curCol);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "K": { // EL
|
|
108
|
+
const mode = n(0, 0);
|
|
109
|
+
if (mode === 0) this.clearRegion(this.curRow, this.curCol, this.curRow, -1);
|
|
110
|
+
else if (mode === 1) this.clearRegion(this.curRow, 0, this.curRow, this.curCol);
|
|
111
|
+
else this.clearRegion(this.curRow, 0, this.curRow, -1);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return; // m(SGR)等其余序列忽略
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private writeChar(ch: string): void {
|
|
120
|
+
if (ch === "\n") { this.lineFeed(); return; }
|
|
121
|
+
if (ch === "\r") { this.curCol = 0; return; }
|
|
122
|
+
if (ch === "\b") { this.curCol = Math.max(0, this.curCol - 1); return; }
|
|
123
|
+
if (ch === "\t") {
|
|
124
|
+
const cols = this.rows[0]!.length;
|
|
125
|
+
this.curCol = Math.min(cols - 1, (Math.floor(this.curCol / 8) + 1) * 8);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (ch < " ") return; // 其余控制字符忽略
|
|
129
|
+
const cols = this.rows[0]!.length;
|
|
130
|
+
if (this.curCol >= cols) { this.curCol = 0; this.lineFeed(); } // autowrap
|
|
131
|
+
this.rows[this.curRow]![this.curCol] = ch;
|
|
132
|
+
this.curCol++;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private lineFeed(): void {
|
|
136
|
+
if (this.curRow >= this.rows.length - 1) {
|
|
137
|
+
this.rows.shift();
|
|
138
|
+
const cols = this.rows[0]!.length;
|
|
139
|
+
this.rows.push(Array.from({ length: cols }, () => " "));
|
|
140
|
+
this.curRow = this.rows.length - 1;
|
|
141
|
+
} else {
|
|
142
|
+
this.curRow++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private moveTo(row: number, col: number): void {
|
|
147
|
+
const cols = this.rows[0]!.length;
|
|
148
|
+
this.curRow = Math.max(0, Math.min(this.rows.length - 1, row));
|
|
149
|
+
this.curCol = Math.max(0, Math.min(cols - 1, col));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** endCol=-1 表示到行尾;endRow 含 */
|
|
153
|
+
private clearRegion(row1: number, col1: number, row2: number, col2: number): void {
|
|
154
|
+
const cols = this.rows[0]!.length;
|
|
155
|
+
for (let r = Math.max(0, row1); r <= Math.min(this.rows.length - 1, row2); r++) {
|
|
156
|
+
const from = r === row1 ? Math.max(0, col1) : 0;
|
|
157
|
+
const to = (col2 === -1 || r < row2) ? cols - 1 : Math.min(cols - 1, col2);
|
|
158
|
+
for (let c = from; c <= to; c++) this.rows[r]![c] = " ";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private clearAll(): void {
|
|
163
|
+
const cols = this.rows[0]!.length;
|
|
164
|
+
this.rows = Array.from({ length: this.rows.length }, () => Array.from({ length: cols }, () => " "));
|
|
165
|
+
}
|
|
166
|
+
}
|