@aexol/spectral 0.9.166 → 0.9.169
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/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +7 -0
- package/dist/cli.js +6 -0
- package/dist/commands/ports.d.ts +13 -0
- package/dist/commands/ports.d.ts.map +1 -0
- package/dist/commands/ports.js +110 -0
- package/dist/extensions/ports/index.d.ts +19 -0
- package/dist/extensions/ports/index.d.ts.map +1 -0
- package/dist/extensions/ports/index.js +154 -0
- package/dist/extensions/ports/platform.d.ts +39 -0
- package/dist/extensions/ports/platform.d.ts.map +1 -0
- package/dist/extensions/ports/platform.js +255 -0
- package/dist/memory/hooks/compaction-trigger.js +1 -1
- package/dist/sdk/agent-core/agent-loop.js +1 -1
- package/dist/sdk/coding-agent/core/agent-session.d.ts +21 -1
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +378 -76
- package/dist/sdk/coding-agent/core/compaction/llm-compaction.d.ts +42 -0
- package/dist/sdk/coding-agent/core/compaction/llm-compaction.d.ts.map +1 -0
- package/dist/sdk/coding-agent/core/compaction/llm-compaction.js +83 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
- package/dist/sdk/coding-agent/core/resource-loader.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/resource-loader.js +23 -1
- package/dist/sdk/coding-agent/core/session-manager.d.ts +50 -1
- package/dist/sdk/coding-agent/core/session-manager.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/session-manager.js +222 -26
- package/dist/sdk/coding-agent/core/settings-manager.d.ts +15 -0
- package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/settings-manager.js +11 -0
- package/dist/server/agent-bridge.d.ts +8 -6
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +28 -18
- package/dist/server/error-humanizer.d.ts.map +1 -1
- package/dist/server/error-humanizer.js +1 -1
- package/dist/server/wire.d.ts +2 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAIX,SAAS,EAET,MAAM,4BAA4B,CAAC;AAUpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EACN,KAAK,WAAW,EAKhB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOrD,OAAO,KAAK,EAAE,gBAAgB,IAAI,oBAAoB,EAAE,MAAM,8CAA8C,CAAC;AAsG7G;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC,GACpF,MAAM,CAYR;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAIX,SAAS,EAET,MAAM,4BAA4B,CAAC;AAUpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EACN,KAAK,WAAW,EAKhB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOrD,OAAO,KAAK,EAAE,gBAAgB,IAAI,oBAAoB,EAAE,MAAM,8CAA8C,CAAC;AAsG7G;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC,GACpF,MAAM,CAYR;AAkID;;;GAGG;AACH,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,GAChD,IAAI,CAAC,oBAAoB,CAAC,gBAAgB,CAAC,EAAE,WAAW,CAAC,CAK3D;AAED,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,EAC/B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,YAAY,EACjB,GAAG,EAAE,gBAAgB,GACnB,SAAS,EAAE,CAwCb;AAicD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IACN,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB,GACC,IAAI,CAgaN"}
|
package/dist/agent/index.js
CHANGED
|
@@ -420,12 +420,16 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
|
|
|
420
420
|
// Accumulated thinking deltas for the live subagent sheet. This stays outside
|
|
421
421
|
// final `SingleResult` so the public subagent tool_result contract is unchanged.
|
|
422
422
|
let thinkingText = "";
|
|
423
|
+
// Responses API reasoning metadata that lets the UI classify accumulated
|
|
424
|
+
// subagent thinking as activity (mirrors the main-session thinking metadata).
|
|
425
|
+
let thinkingMetadata;
|
|
423
426
|
const emitUpdate = () => {
|
|
424
427
|
if (onUpdate) {
|
|
425
428
|
const liveResult = {
|
|
426
429
|
...currentResult,
|
|
427
430
|
...(streamingText ? { streamingText } : {}),
|
|
428
431
|
...(thinkingText ? { thinkingText } : {}),
|
|
432
|
+
...(thinkingMetadata ? { thinkingMetadata } : {}),
|
|
429
433
|
};
|
|
430
434
|
onUpdate({
|
|
431
435
|
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || streamingText || (thinkingText ? "(thinking...)" : "(running...)") }],
|
|
@@ -489,6 +493,9 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
|
|
|
489
493
|
}
|
|
490
494
|
else if (inner?.type === "thinking_delta" && inner.delta) {
|
|
491
495
|
thinkingText += inner.delta;
|
|
496
|
+
if (inner.metadata) {
|
|
497
|
+
thinkingMetadata = { ...(thinkingMetadata ?? {}), ...inner.metadata };
|
|
498
|
+
}
|
|
492
499
|
emitUpdate();
|
|
493
500
|
}
|
|
494
501
|
continue;
|
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ function printHeader() {
|
|
|
36
36
|
" spectral bind Link this directory to an Aexol Studio project",
|
|
37
37
|
" spectral unbind Remove the Aexol Studio project binding",
|
|
38
38
|
" spectral update Check for and install the latest Spectral version",
|
|
39
|
+
" spectral ports Diagnose port collisions across local projects",
|
|
39
40
|
" spectral --version Print version and exit",
|
|
40
41
|
" spectral --help Print this help and exit",
|
|
41
42
|
"",
|
|
@@ -113,6 +114,11 @@ async function main() {
|
|
|
113
114
|
await runUpdate(args.slice(1));
|
|
114
115
|
process.exit(0);
|
|
115
116
|
}
|
|
117
|
+
if (first === "ports") {
|
|
118
|
+
const { runPorts } = await import("./commands/ports.js");
|
|
119
|
+
await runPorts(args.slice(1));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
116
122
|
// No TUI/interactive mode is supported. All other invocations must use
|
|
117
123
|
// explicit subcommands.
|
|
118
124
|
process.stderr.write(`spectral: unknown command "${first ?? ""}".\n` +
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spectral ports` — diagnose port collisions across local projects.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* spectral ports list every listening TCP port + owner
|
|
6
|
+
* spectral ports check <p>... check specific ports and suggest free ones
|
|
7
|
+
* spectral ports free [start] find the first free port from `start`
|
|
8
|
+
*
|
|
9
|
+
* Shares the exact same platform logic as the native `ports` extension, so
|
|
10
|
+
* results are identical whether run from the shell or from inside the agent.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runPorts(args: string[]): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../../src/commands/ports.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA0CH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA0E5D"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spectral ports` — diagnose port collisions across local projects.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* spectral ports list every listening TCP port + owner
|
|
6
|
+
* spectral ports check <p>... check specific ports and suggest free ones
|
|
7
|
+
* spectral ports free [start] find the first free port from `start`
|
|
8
|
+
*
|
|
9
|
+
* Shares the exact same platform logic as the native `ports` extension, so
|
|
10
|
+
* results are identical whether run from the shell or from inside the agent.
|
|
11
|
+
*/
|
|
12
|
+
import pc from "picocolors";
|
|
13
|
+
import { findFreePort, isPortFree, knownPortLabel, scanPorts, } from "../extensions/ports/platform.js";
|
|
14
|
+
function renderTable(entries) {
|
|
15
|
+
if (entries.length === 0)
|
|
16
|
+
return "No listening TCP ports found.";
|
|
17
|
+
const dedup = new Map();
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
const existing = dedup.get(e.port);
|
|
20
|
+
if (!existing || (existing.address === "0.0.0.0" && e.address !== "0.0.0.0")) {
|
|
21
|
+
dedup.set(e.port, { address: e.address, process: e.process, pid: e.pid });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const rows = [...dedup.entries()].sort((a, b) => a[0] - b[0]);
|
|
25
|
+
const portW = Math.max(5, ...rows.map(([p]) => String(p).length));
|
|
26
|
+
const lines = [
|
|
27
|
+
`${rows.length} listening TCP port(s):`,
|
|
28
|
+
"",
|
|
29
|
+
`${"Port".padStart(portW)} ${"Bind".padEnd(22)} Process`,
|
|
30
|
+
`${"─".repeat(portW)} ${"─".repeat(22)} ${"─".repeat(30)}`,
|
|
31
|
+
];
|
|
32
|
+
for (const [port, info] of rows) {
|
|
33
|
+
const known = knownPortLabel(port);
|
|
34
|
+
const label = known ? ` ${known}` : "";
|
|
35
|
+
const owner = info.process ? `${info.process}${info.pid !== null ? ` (pid ${info.pid})` : ""}` : "unknown";
|
|
36
|
+
const bind = info.address === "0.0.0.0" || info.address === "::" || info.address === "::1"
|
|
37
|
+
? "all" : info.address;
|
|
38
|
+
lines.push(`${String(port).padStart(portW)} ${bind.padEnd(22)} ${owner}${label}`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
export async function runPorts(args) {
|
|
43
|
+
const sub = args[0];
|
|
44
|
+
if (sub === "check") {
|
|
45
|
+
const ports = args
|
|
46
|
+
.slice(1)
|
|
47
|
+
.map((p) => Number(p))
|
|
48
|
+
.filter((p) => Number.isInteger(p) && p > 0);
|
|
49
|
+
if (ports.length === 0) {
|
|
50
|
+
process.stderr.write(pc.red("Usage: spectral ports check <port> [<port>...]\n"));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const scan = await scanPorts();
|
|
54
|
+
const owners = new Map();
|
|
55
|
+
for (const e of scan.entries) {
|
|
56
|
+
if (!owners.has(e.port))
|
|
57
|
+
owners.set(e.port, { process: e.process, pid: e.pid });
|
|
58
|
+
}
|
|
59
|
+
for (const port of ports) {
|
|
60
|
+
const owner = owners.get(port);
|
|
61
|
+
const free = owner === undefined && (await isPortFree(port));
|
|
62
|
+
if (free) {
|
|
63
|
+
process.stdout.write(pc.green(`✓ ${port} is free\n`));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const who = owner?.process ? `${owner.process}${owner.pid !== null ? ` (pid ${owner.pid})` : ""}` : "already bound";
|
|
67
|
+
const label = knownPortLabel(port) ? ` — ${knownPortLabel(port)}` : "";
|
|
68
|
+
const alternative = await findFreePort(port);
|
|
69
|
+
process.stdout.write(pc.red(`✗ ${port} is taken by ${who}${label}\n`));
|
|
70
|
+
process.stdout.write(pc.dim(` first free port from ${port}: ${alternative ?? "none found"}\n`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (sub === "free") {
|
|
76
|
+
const start = Number(args[1]) || 3000;
|
|
77
|
+
const port = await findFreePort(start);
|
|
78
|
+
if (port === null) {
|
|
79
|
+
process.stderr.write(pc.red(`No free port found from ${start}\n`));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const label = knownPortLabel(port) ? ` (usually: ${knownPortLabel(port)})` : "";
|
|
83
|
+
process.stdout.write(pc.green(`✓ First free port from ${start}: ${port}${label}\n`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (sub === "--help" || sub === "-h") {
|
|
87
|
+
process.stdout.write([
|
|
88
|
+
"Usage:",
|
|
89
|
+
" spectral ports list listening TCP ports + owners",
|
|
90
|
+
" spectral ports check <p>... check specific ports and suggest free ones",
|
|
91
|
+
" spectral ports free [start] find first free port from `start` (default 3000)",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n"));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
// Default: full scan.
|
|
97
|
+
if (sub === undefined) {
|
|
98
|
+
try {
|
|
99
|
+
const scan = await scanPorts();
|
|
100
|
+
process.stdout.write(`${renderTable(scan.entries)}\n`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
process.stderr.write(pc.red(`Port scan failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
process.stderr.write(pc.red(`Unknown ports subcommand: ${sub}\n`));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports — native spectral extension.
|
|
3
|
+
*
|
|
4
|
+
* Helps resolve the classic "many projects on one machine" problem where
|
|
5
|
+
* Postgres, NATS, Debezium, MinIO, Redis, etc. collide on the same ports.
|
|
6
|
+
*
|
|
7
|
+
* Registers three read-only/free tools:
|
|
8
|
+
* - ports_scan list every listening TCP port + owning process
|
|
9
|
+
* - ports_check check specific ports (status + owner + suggestion)
|
|
10
|
+
* - ports_find_free find the first free port from a starting point
|
|
11
|
+
*
|
|
12
|
+
* No external dependencies. On macOS we parse `lsof`, on Linux `ss`/`netstat`,
|
|
13
|
+
* and on Windows `netstat`. Port reservation for CI/docker is left to the
|
|
14
|
+
* user's compose/env files; this extension is the diagnostic + allocation
|
|
15
|
+
* layer that gives the agent the facts it needs to fix collisions.
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionAPI } from "../../sdk/coding-agent/index.js";
|
|
18
|
+
export default function portsExtension(ext: ExtensionAPI): Promise<void>;
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/ports/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAmB,YAAY,EAAkB,MAAM,iCAAiC,CAAC;AAuDrG,wBAA8B,cAAc,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA+F7E"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports — native spectral extension.
|
|
3
|
+
*
|
|
4
|
+
* Helps resolve the classic "many projects on one machine" problem where
|
|
5
|
+
* Postgres, NATS, Debezium, MinIO, Redis, etc. collide on the same ports.
|
|
6
|
+
*
|
|
7
|
+
* Registers three read-only/free tools:
|
|
8
|
+
* - ports_scan list every listening TCP port + owning process
|
|
9
|
+
* - ports_check check specific ports (status + owner + suggestion)
|
|
10
|
+
* - ports_find_free find the first free port from a starting point
|
|
11
|
+
*
|
|
12
|
+
* No external dependencies. On macOS we parse `lsof`, on Linux `ss`/`netstat`,
|
|
13
|
+
* and on Windows `netstat`. Port reservation for CI/docker is left to the
|
|
14
|
+
* user's compose/env files; this extension is the diagnostic + allocation
|
|
15
|
+
* layer that gives the agent the facts it needs to fix collisions.
|
|
16
|
+
*/
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { knownPortLabel, scanPorts, isPortFree, findFreePort } from "./platform.js";
|
|
19
|
+
function errorResult(msg) {
|
|
20
|
+
return { content: [{ type: "text", text: `❌ ${msg}` }], details: { isError: true, error: msg } };
|
|
21
|
+
}
|
|
22
|
+
function renderScan(entries) {
|
|
23
|
+
if (entries.length === 0)
|
|
24
|
+
return "No listening TCP ports found.";
|
|
25
|
+
const dedup = new Map();
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
// Prefer a non-wildcard address when the same port appears twice (IPv4 + IPv6).
|
|
28
|
+
const existing = dedup.get(e.port);
|
|
29
|
+
if (!existing || (existing.address === "0.0.0.0" && e.address !== "0.0.0.0")) {
|
|
30
|
+
dedup.set(e.port, { address: e.address, process: e.process, pid: e.pid });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const rows = [...dedup.entries()].sort((a, b) => a[0] - b[0]);
|
|
34
|
+
const portW = Math.max(5, ...rows.map(([p]) => String(p).length));
|
|
35
|
+
const rowsText = rows.map(([port, info]) => {
|
|
36
|
+
const known = knownPortLabel(port);
|
|
37
|
+
const label = known ? ` ${known}` : "";
|
|
38
|
+
const owner = info.process ? `${info.process}${info.pid !== null ? ` (pid ${info.pid})` : ""}` : "unknown";
|
|
39
|
+
const bind = info.address === "0.0.0.0" || info.address === "::" || info.address === "::1"
|
|
40
|
+
? "all" : info.address;
|
|
41
|
+
return `${String(port).padStart(portW)} ${bind.padEnd(22)} ${owner}${label}`;
|
|
42
|
+
});
|
|
43
|
+
return [
|
|
44
|
+
`${rows.length} listening TCP port(s):`,
|
|
45
|
+
"",
|
|
46
|
+
`${"Port".padStart(portW)} ${"Bind".padEnd(22)} Process`,
|
|
47
|
+
`${"─".repeat(portW)} ${"─".repeat(22)} ${"─".repeat(30)}`,
|
|
48
|
+
...rowsText,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
const scanSchema = Type.Object({});
|
|
52
|
+
const checkSchema = Type.Object({
|
|
53
|
+
ports: Type.Array(Type.Number(), { description: "Port numbers to check (e.g. [5432, 4222, 9000])." }),
|
|
54
|
+
host: Type.Optional(Type.String({ default: "0.0.0.0", description: "Bind host to test for free ports. Default: 0.0.0.0." })),
|
|
55
|
+
});
|
|
56
|
+
const findSchema = Type.Object({
|
|
57
|
+
start: Type.Optional(Type.Number({ default: 3000, description: "Start searching from this port. Default: 3000." })),
|
|
58
|
+
host: Type.Optional(Type.String({ default: "0.0.0.0", description: "Bind host to test. Default: 0.0.0.0." })),
|
|
59
|
+
max: Type.Optional(Type.Number({ default: 65535, description: "Upper bound. Default: 65535." })),
|
|
60
|
+
});
|
|
61
|
+
export default async function portsExtension(ext) {
|
|
62
|
+
const scanTool = {
|
|
63
|
+
name: "ports_scan",
|
|
64
|
+
label: "Scan Ports",
|
|
65
|
+
description: "List every listening TCP port on this machine with its bind address and owning process. " +
|
|
66
|
+
"Use this when services from multiple projects (Postgres, NATS, Debezium, MinIO, Redis, etc.) " +
|
|
67
|
+
"may be colliding on the same ports. Read-only.",
|
|
68
|
+
promptSnippet: "`ports_scan` — list listening TCP ports and their owning processes",
|
|
69
|
+
parameters: scanSchema,
|
|
70
|
+
async execute(_toolCallId, _params) {
|
|
71
|
+
try {
|
|
72
|
+
const result = await scanPorts();
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: renderScan(result.entries) }],
|
|
75
|
+
details: { count: result.entries.length, platform: result.platform, command: result.command },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const checkTool = {
|
|
84
|
+
name: "ports_check",
|
|
85
|
+
label: "Check Ports",
|
|
86
|
+
description: "Check whether specific TCP ports are free or taken. Returns the owner process for taken ports " +
|
|
87
|
+
"and the first free alternative for requested ones. Use before starting another project's stack.",
|
|
88
|
+
promptSnippet: "`ports_check` — check status of specific ports and suggest free alternatives",
|
|
89
|
+
parameters: checkSchema,
|
|
90
|
+
async execute(_toolCallId, params) {
|
|
91
|
+
const ports = params.ports.map((p) => Number(p)).filter((p) => Number.isInteger(p) && p > 0);
|
|
92
|
+
if (ports.length === 0)
|
|
93
|
+
return errorResult("Provide at least one valid port in `ports`.");
|
|
94
|
+
const host = params.host ?? "0.0.0.0";
|
|
95
|
+
try {
|
|
96
|
+
const scan = await scanPorts();
|
|
97
|
+
const owners = new Map();
|
|
98
|
+
for (const e of scan.entries) {
|
|
99
|
+
if (!owners.has(e.port))
|
|
100
|
+
owners.set(e.port, { process: e.process, pid: e.pid });
|
|
101
|
+
}
|
|
102
|
+
const lines = [];
|
|
103
|
+
for (const port of ports) {
|
|
104
|
+
const owner = owners.get(port);
|
|
105
|
+
const free = owner === undefined && (await isPortFree(port, host));
|
|
106
|
+
if (free) {
|
|
107
|
+
lines.push(`✓ ${port} is free (bindable on ${host}).`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const who = owner?.process ? `${owner.process}${owner.pid !== null ? ` (pid ${owner.pid})` : ""}` : "already bound";
|
|
111
|
+
const label = knownPortLabel(port) ? ` — ${knownPortLabel(port)}` : "";
|
|
112
|
+
const alternative = await findFreePort(port, 65535, host);
|
|
113
|
+
lines.push(`✗ ${port} is taken by ${who}${label}. First free port from ${port}: ${alternative ?? "none found"}.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { ports, host } };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const findTool = {
|
|
124
|
+
name: "ports_find_free",
|
|
125
|
+
label: "Find Free Port",
|
|
126
|
+
description: "Find the first free TCP port at or after a starting port. Useful when allocating a per-project " +
|
|
127
|
+
"offset for a new stack without colliding with services already running on this machine.",
|
|
128
|
+
promptSnippet: "`ports_find_free` — find the first free port from a starting point",
|
|
129
|
+
parameters: findSchema,
|
|
130
|
+
async execute(_toolCallId, params) {
|
|
131
|
+
const start = Number(params.start) || 3000;
|
|
132
|
+
const max = Number(params.max) || 65535;
|
|
133
|
+
const host = params.host ?? "0.0.0.0";
|
|
134
|
+
try {
|
|
135
|
+
const port = await findFreePort(start, max, host);
|
|
136
|
+
if (port === null) {
|
|
137
|
+
return { content: [{ type: "text", text: `No free port found between ${start} and ${max} on ${host}.` }], details: { start, max, host } };
|
|
138
|
+
}
|
|
139
|
+
const label = knownPortLabel(port) ? ` (usually: ${knownPortLabel(port)})` : "";
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: `✓ First free port from ${start} on ${host}: ${port}${label}.` }],
|
|
142
|
+
details: { port, start, max, host },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
ext.registerTool(scanTool);
|
|
151
|
+
ext.registerTool(checkTool);
|
|
152
|
+
ext.registerTool(findTool);
|
|
153
|
+
process.stderr.write(`[ports] Registered ports_scan + ports_check + ports_find_free (platform: ${process.platform}).\n`);
|
|
154
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform port introspection for the ports extension.
|
|
3
|
+
*
|
|
4
|
+
* Zero external dependencies. We shell out to the OS's native tooling and
|
|
5
|
+
* parse its output, so a machine with many projects/services can be inspected
|
|
6
|
+
* without requiring `lsof` from JS bindings or admin privileges.
|
|
7
|
+
*
|
|
8
|
+
* Detection order per platform:
|
|
9
|
+
* - darwin: lsof -nP -iTCP -sTCP:LISTEN
|
|
10
|
+
* - linux: ss -ltnp (fallback: netstat -ltnp)
|
|
11
|
+
* - win32: netstat -ano (pids are resolved in a second best-effort pass)
|
|
12
|
+
*/
|
|
13
|
+
export interface PortEntry {
|
|
14
|
+
port: number;
|
|
15
|
+
protocol: "tcp" | "udp";
|
|
16
|
+
address: string;
|
|
17
|
+
pid: number | null;
|
|
18
|
+
process: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface PortScanResult {
|
|
21
|
+
platform: string;
|
|
22
|
+
command: string;
|
|
23
|
+
entries: PortEntry[];
|
|
24
|
+
}
|
|
25
|
+
/** Well-known services so the report can label ports humans recognize. */
|
|
26
|
+
export declare const KNOWN_PORTS: Record<number, string>;
|
|
27
|
+
export declare function knownPortLabel(port: number): string | undefined;
|
|
28
|
+
export declare function scanPorts(): Promise<PortScanResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Check a single port using a real bind attempt, which is the only reliable
|
|
31
|
+
* cross-platform way to know whether a port is actually free (a service may be
|
|
32
|
+
* listening only on a specific interface, so a "scan" alone is not enough).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isPortFree(port: number, host?: string): Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Find the first free port at or after `start`, bounded by `max`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findFreePort(start: number, max?: number, host?: string): Promise<number | null>;
|
|
39
|
+
//# sourceMappingURL=platform.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../../src/extensions/ports/platform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,KAAK,GAAG,KAAK,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,EAAE,CAAC;CACrB;AAED,0EAA0E;AAC1E,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAuBrC,CAAC;AAEX,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE/D;AA+JD,wBAAsB,SAAS,IAAI,OAAO,CAAC,cAAc,CAAC,CA+BzD;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAWjF;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAQ,EAAE,IAAI,SAAY,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKvG"}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform port introspection for the ports extension.
|
|
3
|
+
*
|
|
4
|
+
* Zero external dependencies. We shell out to the OS's native tooling and
|
|
5
|
+
* parse its output, so a machine with many projects/services can be inspected
|
|
6
|
+
* without requiring `lsof` from JS bindings or admin privileges.
|
|
7
|
+
*
|
|
8
|
+
* Detection order per platform:
|
|
9
|
+
* - darwin: lsof -nP -iTCP -sTCP:LISTEN
|
|
10
|
+
* - linux: ss -ltnp (fallback: netstat -ltnp)
|
|
11
|
+
* - win32: netstat -ano (pids are resolved in a second best-effort pass)
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { platform as osPlatform } from "node:os";
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
/** Well-known services so the report can label ports humans recognize. */
|
|
18
|
+
export const KNOWN_PORTS = {
|
|
19
|
+
3000: "React/Next dev",
|
|
20
|
+
3008: "Aexol Studio (landing)",
|
|
21
|
+
4000: "backend dev (generic)",
|
|
22
|
+
4008: "Aexol backend (GraphQL)",
|
|
23
|
+
4222: "NATS",
|
|
24
|
+
5432: "PostgreSQL (default)",
|
|
25
|
+
5433: "PostgreSQL (secondary)",
|
|
26
|
+
5434: "PostgreSQL (Aexol dev)",
|
|
27
|
+
5435: "PostgreSQL (Aexol test)",
|
|
28
|
+
6379: "Redis",
|
|
29
|
+
7000: "ControlCenter/airplay",
|
|
30
|
+
8000: "http dev (generic)",
|
|
31
|
+
8080: "http proxy (generic)",
|
|
32
|
+
8081: "http admin (generic)",
|
|
33
|
+
8083: "Debezium Connect",
|
|
34
|
+
8085: "MinIO API",
|
|
35
|
+
8086: "MinIO console (legacy)",
|
|
36
|
+
9000: "MinIO / S3 gateway",
|
|
37
|
+
9001: "MinIO console",
|
|
38
|
+
9092: "Kafka",
|
|
39
|
+
9200: "Elasticsearch",
|
|
40
|
+
27017: "MongoDB",
|
|
41
|
+
};
|
|
42
|
+
export function knownPortLabel(port) {
|
|
43
|
+
return KNOWN_PORTS[port];
|
|
44
|
+
}
|
|
45
|
+
function parsePort(raw) {
|
|
46
|
+
const n = Number(raw);
|
|
47
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
48
|
+
}
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
// macOS
|
|
51
|
+
// ===========================================================================
|
|
52
|
+
function parseLsof(output) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (const line of output.split("\n")) {
|
|
55
|
+
if (!line.includes("(LISTEN)"))
|
|
56
|
+
continue;
|
|
57
|
+
const parts = line.trim().split(/\s+/);
|
|
58
|
+
if (parts.length < 3)
|
|
59
|
+
continue;
|
|
60
|
+
// lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
61
|
+
// NAME looks like `*:5432` or `127.0.0.1:3008` and is followed by `(LISTEN)`.
|
|
62
|
+
const tcpIndex = parts.findIndex((p) => p === "TCP");
|
|
63
|
+
const name = tcpIndex >= 0 ? parts[tcpIndex + 1] : undefined;
|
|
64
|
+
if (!name)
|
|
65
|
+
continue;
|
|
66
|
+
const addrPort = name.slice(name.lastIndexOf(":") + 1);
|
|
67
|
+
const port = parsePort(addrPort);
|
|
68
|
+
if (port === null)
|
|
69
|
+
continue;
|
|
70
|
+
const command = parts[0] ?? null;
|
|
71
|
+
const pid = parsePort(parts[1]) ?? null;
|
|
72
|
+
// Normalize the bind address: `*:5432` → 0.0.0.0, `[::1]:19876` → ::1.
|
|
73
|
+
let address;
|
|
74
|
+
if (name.startsWith("*")) {
|
|
75
|
+
address = "0.0.0.0";
|
|
76
|
+
}
|
|
77
|
+
else if (name.startsWith("[")) {
|
|
78
|
+
address = name.slice(1, name.indexOf("]"));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
address = name.slice(0, name.lastIndexOf(":"));
|
|
82
|
+
}
|
|
83
|
+
entries.push({
|
|
84
|
+
port,
|
|
85
|
+
protocol: "tcp",
|
|
86
|
+
address,
|
|
87
|
+
pid,
|
|
88
|
+
process: command,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
// ===========================================================================
|
|
94
|
+
// Linux
|
|
95
|
+
// ===========================================================================
|
|
96
|
+
function parseSs(output) {
|
|
97
|
+
const entries = [];
|
|
98
|
+
for (const line of output.split("\n")) {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
if (!trimmed.startsWith("LISTEN"))
|
|
101
|
+
continue;
|
|
102
|
+
const parts = trimmed.split(/\s+/);
|
|
103
|
+
// ss layout: LISTEN 0 511 0.0.0.0:5432 0.0.0.0:* users:(("postgres",pid=123,fd=5))
|
|
104
|
+
const local = parts.find((p) => /^[0-9.:\[\]]+:\d+$/.test(p) && !p.endsWith(":*"));
|
|
105
|
+
if (!local)
|
|
106
|
+
continue;
|
|
107
|
+
const port = parsePort(local.slice(local.lastIndexOf(":") + 1));
|
|
108
|
+
if (port === null)
|
|
109
|
+
continue;
|
|
110
|
+
const addr = local.startsWith("[") ? local.slice(1, local.indexOf("]")) : local.split(":")[0];
|
|
111
|
+
const userInfo = parts.find((p) => p.startsWith("users:"));
|
|
112
|
+
let process = null;
|
|
113
|
+
let pid = null;
|
|
114
|
+
if (userInfo) {
|
|
115
|
+
const procMatch = userInfo.match(/\("([^"]+)"/);
|
|
116
|
+
const pidMatch = userInfo.match(/pid=(\d+)/);
|
|
117
|
+
process = procMatch ? procMatch[1] : null;
|
|
118
|
+
pid = pidMatch ? Number(pidMatch[1]) : null;
|
|
119
|
+
}
|
|
120
|
+
entries.push({ port, protocol: "tcp", address: addr || "0.0.0.0", pid, process });
|
|
121
|
+
}
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
function parseNetstat(output) {
|
|
125
|
+
const entries = [];
|
|
126
|
+
for (const line of output.split("\n")) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!/(^|\s)(LISTEN|0\.0\.0\.0)/.test(trimmed))
|
|
129
|
+
continue;
|
|
130
|
+
const parts = trimmed.split(/\s+/);
|
|
131
|
+
const local = parts.find((p) => /:\d+$/.test(p) && !p.endsWith(":*"));
|
|
132
|
+
if (!local)
|
|
133
|
+
continue;
|
|
134
|
+
const port = parsePort(local.slice(local.lastIndexOf(":") + 1));
|
|
135
|
+
if (port === null)
|
|
136
|
+
continue;
|
|
137
|
+
const addr = local.startsWith("[") ? local.slice(1, local.indexOf("]")) : local.split(":")[0];
|
|
138
|
+
const process = parts[parts.length - 1]?.includes("/") ? parts[parts.length - 1].split("/")[0] : null;
|
|
139
|
+
const pid = parts[parts.length - 1]?.includes("/") ? Number(parts[parts.length - 1].split("/")[1]) : null;
|
|
140
|
+
entries.push({ port, protocol: "tcp", address: addr || "0.0.0.0", pid: pid || null, process });
|
|
141
|
+
}
|
|
142
|
+
return entries;
|
|
143
|
+
}
|
|
144
|
+
// ===========================================================================
|
|
145
|
+
// Windows
|
|
146
|
+
// ===========================================================================
|
|
147
|
+
async function resolveWinPids(entries) {
|
|
148
|
+
const pids = [...new Set(entries.map((e) => e.pid).filter((p) => p !== null))];
|
|
149
|
+
if (pids.length === 0)
|
|
150
|
+
return;
|
|
151
|
+
const query = pids.map((p) => `ProcessId=${p}`).join(",");
|
|
152
|
+
try {
|
|
153
|
+
const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-Command", String.raw `Get-CimInstance Win32_Process -Filter '${query}' | ForEach-Object { "$($_.ProcessId)\t$($_.Name)" }`], { timeout: 10_000, windowsHide: true });
|
|
154
|
+
const nameByPid = new Map();
|
|
155
|
+
for (const line of stdout.split("\n")) {
|
|
156
|
+
const [pidStr, ...nameParts] = line.trim().split("\t");
|
|
157
|
+
const pid = parsePort(pidStr);
|
|
158
|
+
if (pid && nameParts.length)
|
|
159
|
+
nameByPid.set(pid, nameParts.join("\t"));
|
|
160
|
+
}
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const name = entry.pid !== null ? nameByPid.get(entry.pid) : undefined;
|
|
163
|
+
entry.process = name ?? null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// process names are best-effort; pids remain valid
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function parseNetstatWin(output) {
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (const line of output.split("\n")) {
|
|
173
|
+
const trimmed = line.trim();
|
|
174
|
+
if (!/^TCP\s/.test(trimmed) || !trimmed.includes("LISTENING"))
|
|
175
|
+
continue;
|
|
176
|
+
const parts = trimmed.split(/\s+/);
|
|
177
|
+
const local = parts[1];
|
|
178
|
+
const pidStr = parts[parts.length - 1];
|
|
179
|
+
const port = parsePort(local.slice(local.lastIndexOf(":") + 1));
|
|
180
|
+
if (port === null)
|
|
181
|
+
continue;
|
|
182
|
+
const pid = parsePort(pidStr);
|
|
183
|
+
entries.push({
|
|
184
|
+
port,
|
|
185
|
+
protocol: "tcp",
|
|
186
|
+
address: local.startsWith("[") ? local.slice(1, local.indexOf("]")) : local.split(":")[0],
|
|
187
|
+
pid,
|
|
188
|
+
process: null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return entries;
|
|
192
|
+
}
|
|
193
|
+
// ===========================================================================
|
|
194
|
+
// Unified API
|
|
195
|
+
// ===========================================================================
|
|
196
|
+
export async function scanPorts() {
|
|
197
|
+
switch (osPlatform()) {
|
|
198
|
+
case "darwin": {
|
|
199
|
+
const { stdout } = await execFileAsync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
|
|
200
|
+
timeout: 10_000,
|
|
201
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
202
|
+
});
|
|
203
|
+
return { platform: "darwin", command: "lsof -nP -iTCP -sTCP:LISTEN", entries: parseLsof(stdout) };
|
|
204
|
+
}
|
|
205
|
+
case "linux": {
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await execFileAsync("ss", ["-ltnp"], { timeout: 10_000, maxBuffer: 10 * 1024 * 1024 });
|
|
208
|
+
return { platform: "linux", command: "ss -ltnp", entries: parseSs(stdout) };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
const { stdout } = await execFileAsync("netstat", ["-ltnp"], { timeout: 10_000, maxBuffer: 10 * 1024 * 1024 });
|
|
212
|
+
return { platform: "linux", command: "netstat -ltnp", entries: parseNetstat(stdout) };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
case "win32": {
|
|
216
|
+
const { stdout } = await execFileAsync("netstat", ["-ano"], {
|
|
217
|
+
timeout: 10_000,
|
|
218
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
});
|
|
221
|
+
const entries = parseNetstatWin(stdout);
|
|
222
|
+
await resolveWinPids(entries);
|
|
223
|
+
return { platform: "win32", command: "netstat -ano", entries };
|
|
224
|
+
}
|
|
225
|
+
default:
|
|
226
|
+
throw new Error(`Port scanning is not supported on ${osPlatform()}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Check a single port using a real bind attempt, which is the only reliable
|
|
231
|
+
* cross-platform way to know whether a port is actually free (a service may be
|
|
232
|
+
* listening only on a specific interface, so a "scan" alone is not enough).
|
|
233
|
+
*/
|
|
234
|
+
export async function isPortFree(port, host = "0.0.0.0") {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
import("node:net").then(({ createServer }) => {
|
|
237
|
+
const server = createServer();
|
|
238
|
+
server.once("error", () => resolve(false));
|
|
239
|
+
server.once("listening", () => {
|
|
240
|
+
server.close(() => resolve(true));
|
|
241
|
+
});
|
|
242
|
+
server.listen(port, host);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Find the first free port at or after `start`, bounded by `max`.
|
|
248
|
+
*/
|
|
249
|
+
export async function findFreePort(start, max = 65535, host = "0.0.0.0") {
|
|
250
|
+
for (let port = Math.max(1, start); port <= Math.min(65535, max); port++) {
|
|
251
|
+
if (await isPortFree(port, host))
|
|
252
|
+
return port;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|