agent-yes 1.72.3 → 1.73.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/dist/{SUPPORTED_CLIS-C-KnmE0Y.js → SUPPORTED_CLIS-DgHs-Q6i.js} +129 -28
- package/dist/cli.js +181 -32
- package/dist/index.js +1 -1
- package/dist/{tray-BQkynk6r.js → tray-CPpdxTV-.js} +1 -10
- package/package.json +8 -3
- package/ts/cli.ts +6 -6
- package/ts/index.ts +86 -99
- package/ts/parseCliArgs.spec.ts +88 -0
- package/ts/parseCliArgs.ts +45 -6
- package/ts/rustBinary.ts +68 -0
- package/ts/tray.spec.ts +0 -56
- package/ts/tray.ts +0 -12
- package/ts/versionChecker.spec.ts +73 -2
- package/ts/versionChecker.ts +118 -27
- package/ts/xterm-proxy.ts +130 -0
package/ts/tray.ts
CHANGED
|
@@ -5,7 +5,6 @@ import path from "path";
|
|
|
5
5
|
import { getRunningAgentCount, type Task } from "./runningLock.ts";
|
|
6
6
|
|
|
7
7
|
const POLL_INTERVAL = 2000;
|
|
8
|
-
const IDLE_EXIT_POLLS = 15; // Exit after 15 polls (~30s) with 0 agents
|
|
9
8
|
|
|
10
9
|
const getTrayDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
|
|
11
10
|
const getTrayPidFile = () => path.join(getTrayDir(), "tray.pid");
|
|
@@ -177,21 +176,10 @@ export async function startTray(): Promise<void> {
|
|
|
177
176
|
|
|
178
177
|
// Poll and update, auto-exit after ~30s idle (0 agents)
|
|
179
178
|
let lastCount = count;
|
|
180
|
-
let idlePolls = count === 0 ? 1 : 0;
|
|
181
179
|
intervalId = setInterval(async () => {
|
|
182
180
|
try {
|
|
183
181
|
const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
|
|
184
182
|
|
|
185
|
-
if (newCount === 0) {
|
|
186
|
-
idlePolls++;
|
|
187
|
-
if (idlePolls >= IDLE_EXIT_POLLS) {
|
|
188
|
-
cleanup();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
idlePolls = 0;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
183
|
if (newCount !== lastCount) {
|
|
196
184
|
lastCount = newCount;
|
|
197
185
|
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
compareVersions,
|
|
5
5
|
fetchLatestVersion,
|
|
6
6
|
displayVersion,
|
|
7
|
+
detectInstallMethod,
|
|
8
|
+
versionString,
|
|
7
9
|
} from "./versionChecker";
|
|
8
10
|
|
|
9
11
|
vi.mock("execa", () => ({ execaCommand: vi.fn().mockResolvedValue({}) }));
|
|
@@ -12,6 +14,24 @@ vi.mock("fs/promises", () => ({
|
|
|
12
14
|
readFile: vi.fn(),
|
|
13
15
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
14
16
|
}));
|
|
17
|
+
vi.mock("fs", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
// Return false for .git checks so the dev-checkout guard doesn't skip auto-update in tests
|
|
22
|
+
existsSync: vi.fn(() => false),
|
|
23
|
+
lstatSync: actual.lstatSync,
|
|
24
|
+
readlinkSync: actual.readlinkSync,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
vi.mock("child_process", () => ({
|
|
28
|
+
execFileSync: vi.fn(() => {
|
|
29
|
+
// Simulate successful re-exec by throwing an exit-like error
|
|
30
|
+
const err = new Error("re-exec") as any;
|
|
31
|
+
err.status = 0;
|
|
32
|
+
throw err;
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
15
35
|
|
|
16
36
|
describe("versionChecker", () => {
|
|
17
37
|
describe("compareVersions", () => {
|
|
@@ -81,7 +101,10 @@ describe("versionChecker", () => {
|
|
|
81
101
|
vi.clearAllMocks();
|
|
82
102
|
vi.stubGlobal("fetch", vi.fn());
|
|
83
103
|
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
104
|
+
// Use a mock for process.exit to prevent actual exit in tests
|
|
105
|
+
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
84
106
|
delete process.env.AGENT_YES_NO_UPDATE;
|
|
107
|
+
delete process.env.AGENT_YES_UPDATED;
|
|
85
108
|
delete process.env.BUN_INSTALL;
|
|
86
109
|
});
|
|
87
110
|
|
|
@@ -95,6 +118,21 @@ describe("versionChecker", () => {
|
|
|
95
118
|
expect(fetch).not.toHaveBeenCalled();
|
|
96
119
|
});
|
|
97
120
|
|
|
121
|
+
it("should skip when running from a git dev checkout", async () => {
|
|
122
|
+
const fs = await import("fs");
|
|
123
|
+
// Make the .git check return true so the dev-checkout guard triggers
|
|
124
|
+
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
|
|
125
|
+
await checkAndAutoUpdate();
|
|
126
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should skip when AGENT_YES_UPDATED matches current version", async () => {
|
|
130
|
+
const pkg = await import("../package.json");
|
|
131
|
+
process.env.AGENT_YES_UPDATED = pkg.default.version;
|
|
132
|
+
await checkAndAutoUpdate();
|
|
133
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
98
136
|
it("should use cached result within TTL and not install when up-to-date", async () => {
|
|
99
137
|
const { readFile } = await import("fs/promises");
|
|
100
138
|
vi.mocked(readFile).mockResolvedValueOnce(
|
|
@@ -105,19 +143,23 @@ describe("versionChecker", () => {
|
|
|
105
143
|
expect(process.stderr.write).not.toHaveBeenCalled();
|
|
106
144
|
});
|
|
107
145
|
|
|
108
|
-
it("should install from cache when cached version is newer and within TTL", async () => {
|
|
146
|
+
it("should install and re-exec from cache when cached version is newer and within TTL", async () => {
|
|
109
147
|
const { readFile } = await import("fs/promises");
|
|
110
148
|
const { execaCommand } = await import("execa");
|
|
149
|
+
const { execFileSync } = await import("child_process");
|
|
111
150
|
vi.mocked(readFile).mockResolvedValueOnce(
|
|
112
151
|
JSON.stringify({ checkedAt: Date.now(), latestVersion: "999.0.0" }) as any,
|
|
113
152
|
);
|
|
114
153
|
await checkAndAutoUpdate();
|
|
115
154
|
expect(execaCommand).toHaveBeenCalled();
|
|
155
|
+
expect(execFileSync).toHaveBeenCalled();
|
|
156
|
+
expect(process.exit).toHaveBeenCalled();
|
|
116
157
|
});
|
|
117
158
|
|
|
118
|
-
it("should fetch and write cache when stale, install if behind", async () => {
|
|
159
|
+
it("should fetch and write cache when stale, install and re-exec if behind", async () => {
|
|
119
160
|
const { readFile, writeFile } = await import("fs/promises");
|
|
120
161
|
const { execaCommand } = await import("execa");
|
|
162
|
+
const { execFileSync } = await import("child_process");
|
|
121
163
|
vi.mocked(readFile).mockRejectedValueOnce(new Error("no cache"));
|
|
122
164
|
vi.mocked(fetch).mockResolvedValue({
|
|
123
165
|
ok: true,
|
|
@@ -126,6 +168,7 @@ describe("versionChecker", () => {
|
|
|
126
168
|
await checkAndAutoUpdate();
|
|
127
169
|
expect(writeFile).toHaveBeenCalled();
|
|
128
170
|
expect(execaCommand).toHaveBeenCalled();
|
|
171
|
+
expect(execFileSync).toHaveBeenCalled();
|
|
129
172
|
});
|
|
130
173
|
|
|
131
174
|
it("should fetch and write cache but not install if up-to-date", async () => {
|
|
@@ -229,4 +272,32 @@ describe("versionChecker", () => {
|
|
|
229
272
|
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("unable to check"));
|
|
230
273
|
});
|
|
231
274
|
});
|
|
275
|
+
|
|
276
|
+
describe("detectInstallMethod", () => {
|
|
277
|
+
it("should return a string", () => {
|
|
278
|
+
const method = detectInstallMethod();
|
|
279
|
+
expect(typeof method).toBe("string");
|
|
280
|
+
expect(method.length).toBeGreaterThan(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should return 'git' when .git exists in parent of script dir", async () => {
|
|
284
|
+
const fs = await import("fs");
|
|
285
|
+
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
|
|
286
|
+
expect(detectInstallMethod()).toBe("git");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("should return 'source' when not in node_modules and no .git", async () => {
|
|
290
|
+
const fs = await import("fs");
|
|
291
|
+
vi.mocked(fs.existsSync).mockReturnValueOnce(false);
|
|
292
|
+
expect(detectInstallMethod()).toBe("source");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("versionString", () => {
|
|
297
|
+
it("should include version and install method", () => {
|
|
298
|
+
const str = versionString();
|
|
299
|
+
expect(str).toContain("agent-yes v");
|
|
300
|
+
expect(str).toMatch(/agent-yes v\d+\.\d+\.\d+ \(.+\)/);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
232
303
|
});
|
package/ts/versionChecker.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
1
2
|
import { execaCommand } from "execa";
|
|
3
|
+
import { existsSync, lstatSync, readlinkSync } from "fs";
|
|
2
4
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
3
5
|
import { homedir } from "os";
|
|
4
6
|
import path from "path";
|
|
@@ -25,59 +27,99 @@ async function writeUpdateCache(data: UpdateCache): Promise<void> {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
function detectPackageManager(): string {
|
|
28
|
-
if (
|
|
30
|
+
if (
|
|
31
|
+
process.env.BUN_INSTALL ||
|
|
32
|
+
process.execPath?.includes("bun") ||
|
|
33
|
+
process.env.npm_execpath?.includes("bun")
|
|
34
|
+
)
|
|
35
|
+
return "bun";
|
|
29
36
|
return "npm";
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
|
-
* Check for updates
|
|
40
|
+
* Check for updates, auto-install if newer version is available, and re-exec
|
|
41
|
+
* so the current invocation always runs the latest code.
|
|
42
|
+
*
|
|
34
43
|
* Uses a 1-hour TTL cache to avoid hitting the registry on every run.
|
|
35
44
|
* All errors are swallowed — network issues must never break the tool.
|
|
36
45
|
* Set AGENT_YES_NO_UPDATE=1 to opt out.
|
|
46
|
+
*
|
|
47
|
+
* The AGENT_YES_UPDATED env var prevents infinite re-exec loops:
|
|
48
|
+
* after updating we re-exec with AGENT_YES_UPDATED=<version> so the
|
|
49
|
+
* new process skips the update check.
|
|
37
50
|
*/
|
|
38
51
|
export async function checkAndAutoUpdate(): Promise<void> {
|
|
39
52
|
if (process.env.AGENT_YES_NO_UPDATE) return;
|
|
40
53
|
|
|
54
|
+
// Prevent infinite re-exec: if we just updated, skip
|
|
55
|
+
if (process.env.AGENT_YES_UPDATED) return;
|
|
56
|
+
|
|
57
|
+
// Skip auto-update when running from a linked local dev checkout (git repo)
|
|
58
|
+
if (import.meta.url.startsWith("file://") && !import.meta.url.includes("node_modules")) {
|
|
59
|
+
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
|
60
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
61
|
+
if (existsSync(path.join(repoRoot, ".git"))) return;
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
try {
|
|
65
|
+
let latestVersion: string | undefined;
|
|
66
|
+
|
|
42
67
|
// Check cache TTL
|
|
43
68
|
const cache = await readUpdateCache();
|
|
44
69
|
if (cache && Date.now() - cache.checkedAt < TTL_MS) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return;
|
|
70
|
+
latestVersion = cache.latestVersion;
|
|
71
|
+
} else {
|
|
72
|
+
// Fetch latest from registry
|
|
73
|
+
const fetched = await fetchLatestVersion();
|
|
74
|
+
if (!fetched) return;
|
|
75
|
+
latestVersion = fetched;
|
|
76
|
+
await writeUpdateCache({ checkedAt: Date.now(), latestVersion });
|
|
50
77
|
}
|
|
51
78
|
|
|
52
|
-
// Fetch latest from registry
|
|
53
|
-
const latestVersion = await fetchLatestVersion();
|
|
54
|
-
if (!latestVersion) return;
|
|
55
|
-
|
|
56
|
-
await writeUpdateCache({ checkedAt: Date.now(), latestVersion });
|
|
57
|
-
|
|
58
79
|
if (compareVersions(pkg.version, latestVersion) < 0) {
|
|
59
|
-
await runInstall(latestVersion);
|
|
80
|
+
const installed = await runInstall(latestVersion);
|
|
81
|
+
if (installed) {
|
|
82
|
+
reExec(latestVersion);
|
|
83
|
+
}
|
|
60
84
|
}
|
|
61
85
|
} catch {
|
|
62
86
|
// Silently ignore all errors
|
|
63
87
|
}
|
|
64
88
|
}
|
|
65
89
|
|
|
66
|
-
async function runInstall(latestVersion: string): Promise<
|
|
90
|
+
async function runInstall(latestVersion: string): Promise<boolean> {
|
|
67
91
|
const pm = detectPackageManager();
|
|
68
|
-
const
|
|
92
|
+
const installCmd =
|
|
69
93
|
pm === "bun"
|
|
70
94
|
? `bun add -g agent-yes@${latestVersion}`
|
|
71
95
|
: `npm install -g agent-yes@${latestVersion}`;
|
|
72
96
|
|
|
73
97
|
process.stderr.write(`\x1b[33m[agent-yes] Updating ${pkg.version} → ${latestVersion}…\x1b[0m\n`);
|
|
74
98
|
try {
|
|
75
|
-
await execaCommand(
|
|
76
|
-
// Clear cache so next run re-checks
|
|
77
|
-
await writeUpdateCache({ checkedAt: 0, latestVersion });
|
|
99
|
+
await execaCommand(installCmd, { stdio: "inherit" });
|
|
78
100
|
process.stderr.write(`\x1b[32m[agent-yes] Updated to ${latestVersion}\x1b[0m\n`);
|
|
101
|
+
return true;
|
|
79
102
|
} catch {
|
|
80
|
-
process.stderr.write(`\x1b[31m[agent-yes] Auto-update failed. Run: ${
|
|
103
|
+
process.stderr.write(`\x1b[31m[agent-yes] Auto-update failed. Run: ${installCmd}\x1b[0m\n`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-exec the current process so the newly installed version runs.
|
|
110
|
+
* Sets AGENT_YES_UPDATED=<version> to prevent an infinite loop.
|
|
111
|
+
*/
|
|
112
|
+
function reExec(version: string): never {
|
|
113
|
+
const [bin, ...args] = process.argv;
|
|
114
|
+
process.stderr.write(`\x1b[36m[agent-yes] Restarting with v${version}…\x1b[0m\n`);
|
|
115
|
+
try {
|
|
116
|
+
execFileSync(bin, args, {
|
|
117
|
+
stdio: "inherit",
|
|
118
|
+
env: { ...process.env, AGENT_YES_UPDATED: version },
|
|
119
|
+
});
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} catch (err: any) {
|
|
122
|
+
process.exit(err.status ?? 1);
|
|
81
123
|
}
|
|
82
124
|
}
|
|
83
125
|
|
|
@@ -121,31 +163,80 @@ export function compareVersions(v1: string, v2: string): number {
|
|
|
121
163
|
return 0;
|
|
122
164
|
}
|
|
123
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Detect how agent-yes was installed.
|
|
168
|
+
* Returns a short label: "git", "bun link", "bun", "npm", "npx", or "unknown"
|
|
169
|
+
*/
|
|
170
|
+
export function detectInstallMethod(): string {
|
|
171
|
+
try {
|
|
172
|
+
// Check if running from a file path outside node_modules (git clone / bun link dev)
|
|
173
|
+
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
|
174
|
+
|
|
175
|
+
if (!scriptDir.includes("node_modules")) {
|
|
176
|
+
// Running directly from source — is this a git repo?
|
|
177
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
178
|
+
if (existsSync(path.join(repoRoot, ".git"))) {
|
|
179
|
+
return "git";
|
|
180
|
+
}
|
|
181
|
+
return "source";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if the node_modules entry is a symlink (bun link)
|
|
185
|
+
const nodeModulesEntry = scriptDir.replace(/\/dist$/, "");
|
|
186
|
+
try {
|
|
187
|
+
const stat = lstatSync(nodeModulesEntry);
|
|
188
|
+
if (stat.isSymbolicLink()) {
|
|
189
|
+
const target = readlinkSync(nodeModulesEntry);
|
|
190
|
+
// bun link creates a symlink to the local repo
|
|
191
|
+
const resolvedTarget = path.resolve(path.dirname(nodeModulesEntry), target);
|
|
192
|
+
if (existsSync(path.join(resolvedTarget, ".git"))) {
|
|
193
|
+
return "bun link (git)";
|
|
194
|
+
}
|
|
195
|
+
return "bun link";
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// not a symlink, continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Detect package manager from path or env
|
|
202
|
+
if (scriptDir.includes(".bun/")) return "bun";
|
|
203
|
+
if (scriptDir.includes(".npm/")) return "npx";
|
|
204
|
+
if (process.env.npm_execpath?.includes("bun")) return "bun";
|
|
205
|
+
if (process.env.npm_config_user_agent?.startsWith("bun")) return "bun";
|
|
206
|
+
if (process.env.npm_config_user_agent?.startsWith("npm")) return "npm";
|
|
207
|
+
|
|
208
|
+
return "npm";
|
|
209
|
+
} catch {
|
|
210
|
+
return "unknown";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Format version string with install method
|
|
216
|
+
*/
|
|
217
|
+
export function versionString(): string {
|
|
218
|
+
return `agent-yes v${pkg.version} (${detectInstallMethod()})`;
|
|
219
|
+
}
|
|
220
|
+
|
|
124
221
|
/**
|
|
125
222
|
* Display version information with async latest version check
|
|
126
223
|
*/
|
|
127
224
|
export async function displayVersion(): Promise<void> {
|
|
128
|
-
|
|
129
|
-
console.log(pkg.version);
|
|
225
|
+
console.log(versionString());
|
|
130
226
|
|
|
131
|
-
// Check latest version asynchronously
|
|
132
227
|
const latestVersion = await fetchLatestVersion();
|
|
133
228
|
|
|
134
229
|
if (latestVersion) {
|
|
135
230
|
const comparison = compareVersions(pkg.version, latestVersion);
|
|
136
231
|
|
|
137
232
|
if (comparison < 0) {
|
|
138
|
-
// Current version is older
|
|
139
233
|
console.log(`\x1b[33m${latestVersion} (update available)\x1b[0m`);
|
|
140
234
|
} else if (comparison > 0) {
|
|
141
|
-
// Current version is newer (pre-release or local dev)
|
|
142
235
|
console.log(`${latestVersion} (latest published)`);
|
|
143
236
|
} else {
|
|
144
|
-
// Versions are equal
|
|
145
237
|
console.log(`${latestVersion} (latest)`);
|
|
146
238
|
}
|
|
147
239
|
} else {
|
|
148
|
-
// Failed to fetch latest version
|
|
149
240
|
console.log("(unable to check for updates)");
|
|
150
241
|
}
|
|
151
242
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import xterm from "@xterm/headless";
|
|
2
|
+
const { Terminal } = xterm;
|
|
3
|
+
import { logger } from "./logger.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* XtermProxy wraps @xterm/headless to act as a full xterm terminal emulator
|
|
7
|
+
* between a PTY process and downstream consumers.
|
|
8
|
+
*
|
|
9
|
+
* It automatically responds to ALL terminal queries (DSR, DA, OSC, etc.)
|
|
10
|
+
* by piping xterm's onData responses back to the PTY — so the spawned
|
|
11
|
+
* process never blocks waiting for a terminal reply, even in non-TTY
|
|
12
|
+
* environments or when the real terminal is backgrounded.
|
|
13
|
+
*/
|
|
14
|
+
export class XtermProxy {
|
|
15
|
+
private term: Terminal;
|
|
16
|
+
private writeToPty: (data: string) => void;
|
|
17
|
+
private readableController: ReadableStreamDefaultController<string> | null = null;
|
|
18
|
+
|
|
19
|
+
/** Downstream readable — passthrough of PTY output for sflow pipeline */
|
|
20
|
+
readonly readable: ReadableStream<string>;
|
|
21
|
+
|
|
22
|
+
constructor(opts: { cols?: number; rows?: number; writeToPty: (data: string) => void }) {
|
|
23
|
+
const cols = opts.cols ?? 80;
|
|
24
|
+
const rows = opts.rows ?? 24;
|
|
25
|
+
this.writeToPty = opts.writeToPty;
|
|
26
|
+
|
|
27
|
+
this.term = new Terminal({
|
|
28
|
+
cols,
|
|
29
|
+
rows,
|
|
30
|
+
allowProposedApi: true,
|
|
31
|
+
scrollback: 10000,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// xterm internally generates responses to terminal queries (DSR, DA, etc.)
|
|
35
|
+
// and fires them via onData. Pipe those back to the PTY stdin.
|
|
36
|
+
this.term.onData((data) => {
|
|
37
|
+
logger.debug("xterm-proxy|onData response", data);
|
|
38
|
+
this.writeToPty(data);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Create a ReadableStream for downstream consumption (sflow pipeline)
|
|
42
|
+
this.readable = new ReadableStream<string>({
|
|
43
|
+
start: (controller) => {
|
|
44
|
+
this.readableController = controller;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Feed PTY output into the xterm emulator.
|
|
51
|
+
* - xterm processes escape sequences and updates internal state
|
|
52
|
+
* - Terminal queries (ESC[6n, ESC[c, etc.) trigger onData → writeToPty
|
|
53
|
+
* - Raw data is pushed to readable for downstream consumption
|
|
54
|
+
*/
|
|
55
|
+
write(data: string): void {
|
|
56
|
+
// Feed to xterm for state tracking and query auto-response first.
|
|
57
|
+
// xterm.write() is buffered/async, so only emit to downstream once the
|
|
58
|
+
// terminal state has been updated for this chunk.
|
|
59
|
+
this.term.write(data, () => {
|
|
60
|
+
try {
|
|
61
|
+
this.readableController?.enqueue(data);
|
|
62
|
+
} catch {
|
|
63
|
+
// Stream already closed/canceled — ignore
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Get cursor position from xterm's buffer state */
|
|
69
|
+
getCursorPosition(): { row: number; col: number } {
|
|
70
|
+
const buf = this.term.buffer.active;
|
|
71
|
+
// xterm uses 0-based; terminal-render used 0-based too
|
|
72
|
+
return { row: buf.cursorY, col: buf.cursorX };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the last N lines of rendered terminal content (plain text, no ANSI).
|
|
77
|
+
* Equivalent to terminal-render's tail(n).
|
|
78
|
+
*/
|
|
79
|
+
tail(n: number): string {
|
|
80
|
+
const buf = this.term.buffer.active;
|
|
81
|
+
const totalLines = buf.length;
|
|
82
|
+
const startLine = Math.max(0, totalLines - n);
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
for (let i = startLine; i < totalLines; i++) {
|
|
85
|
+
const line = buf.getLine(i);
|
|
86
|
+
lines.push(line ? line.translateToString(true) : "");
|
|
87
|
+
}
|
|
88
|
+
// Trim trailing empty lines
|
|
89
|
+
while (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
90
|
+
lines.pop();
|
|
91
|
+
}
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render the full terminal buffer as plain text.
|
|
97
|
+
* Equivalent to terminal-render's render().
|
|
98
|
+
*/
|
|
99
|
+
render(): string {
|
|
100
|
+
const buf = this.term.buffer.active;
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
for (let i = 0; i < buf.length; i++) {
|
|
103
|
+
const line = buf.getLine(i);
|
|
104
|
+
lines.push(line ? line.translateToString(true) : "");
|
|
105
|
+
}
|
|
106
|
+
// Trim trailing empty lines
|
|
107
|
+
while (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
108
|
+
lines.pop();
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resize the virtual terminal */
|
|
114
|
+
resize(cols: number, rows: number): void {
|
|
115
|
+
this.term.resize(cols, rows);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Clean up resources */
|
|
119
|
+
dispose(): void {
|
|
120
|
+
if (this.readableController) {
|
|
121
|
+
try {
|
|
122
|
+
this.readableController.close();
|
|
123
|
+
} catch {
|
|
124
|
+
// Already closed
|
|
125
|
+
}
|
|
126
|
+
this.readableController = null;
|
|
127
|
+
}
|
|
128
|
+
this.term.dispose();
|
|
129
|
+
}
|
|
130
|
+
}
|