paneful 0.9.16 → 0.9.17
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/README.md +3 -0
- package/dist/server/port-monitor.js +125 -185
- package/dist/server/pty-manager.js +13 -0
- package/dist/server/ws-handler.js +1 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,6 +155,9 @@ npm run build
|
|
|
155
155
|
|
|
156
156
|
# Run locally
|
|
157
157
|
npm start
|
|
158
|
+
|
|
159
|
+
# Install the local build globally (replaces any npm-published version)
|
|
160
|
+
npm -g uninstall paneful; npm run build; npm install -g .; paneful --install-app
|
|
158
161
|
```
|
|
159
162
|
|
|
160
163
|
Vite dev server proxies `/ws` and `/api` to `localhost:3000`. Open `http://localhost:5173` or use Chrome in app mode for full keyboard shortcut support:
|
|
@@ -1,110 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
// Match dev-server URLs like http://localhost:3000, http://127.0.0.1:8080, etc.
|
|
5
|
-
const PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1?\]?):(\d{1,5})/g;
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileP = promisify(execFile);
|
|
6
4
|
export class PortMonitor {
|
|
7
|
-
|
|
8
|
-
alivePorts = new Map(); // projectId → alive ports
|
|
9
|
-
pollTimer = null;
|
|
10
|
-
immediatePollTimer = null;
|
|
5
|
+
ptyManager;
|
|
11
6
|
onChange;
|
|
7
|
+
pollTimer = null;
|
|
8
|
+
prevPorts = {};
|
|
12
9
|
destroyed = false;
|
|
13
10
|
polling = false;
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
constructor(ptyManager, onChange) {
|
|
12
|
+
this.ptyManager = ptyManager;
|
|
16
13
|
this.onChange = onChange;
|
|
17
14
|
}
|
|
18
15
|
resume() {
|
|
19
|
-
if (this.destroyed ||
|
|
16
|
+
if (this.destroyed || this.pollTimer)
|
|
20
17
|
return;
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
18
|
+
this.pollTimer = setInterval(() => this.poll(), 3_000);
|
|
19
|
+
this.poll();
|
|
23
20
|
}
|
|
24
21
|
pause() {
|
|
25
|
-
this.paused = true;
|
|
26
22
|
if (this.pollTimer) {
|
|
27
23
|
clearInterval(this.pollTimer);
|
|
28
24
|
this.pollTimer = null;
|
|
29
25
|
}
|
|
30
|
-
if (this.immediatePollTimer) {
|
|
31
|
-
clearTimeout(this.immediatePollTimer);
|
|
32
|
-
this.immediatePollTimer = null;
|
|
33
|
-
}
|
|
34
26
|
}
|
|
35
27
|
getPortStatus() {
|
|
36
|
-
|
|
37
|
-
for (const [pid, ports] of this.alivePorts) {
|
|
38
|
-
result[pid] = [...ports];
|
|
39
|
-
}
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
scanOutput(terminalId, projectId, data) {
|
|
43
|
-
if (this.destroyed)
|
|
44
|
-
return;
|
|
45
|
-
let info = this.terminals.get(terminalId);
|
|
46
|
-
if (!info) {
|
|
47
|
-
info = { projectId, ports: new Set(), lineBuffer: "" };
|
|
48
|
-
this.terminals.set(terminalId, info);
|
|
49
|
-
}
|
|
50
|
-
// Already found ports for this terminal — skip scanning.
|
|
51
|
-
// If the port goes down, the poll scrubs it from info.ports,
|
|
52
|
-
// so scanning resumes automatically on restart.
|
|
53
|
-
if (info.ports.size > 0)
|
|
54
|
-
return;
|
|
55
|
-
// Strip ANSI codes from just the new chunk, then append to line buffer
|
|
56
|
-
const clean = data.replace(ANSI_RE, "");
|
|
57
|
-
const combined = info.lineBuffer + clean;
|
|
58
|
-
const lines = combined.split(/\r?\n/);
|
|
59
|
-
// Keep last incomplete line in buffer, capped to prevent unbounded growth
|
|
60
|
-
const tail = lines.pop() ?? "";
|
|
61
|
-
info.lineBuffer = tail.length > 512 ? tail.slice(-512) : tail;
|
|
62
|
-
let found = false;
|
|
63
|
-
for (const line of lines) {
|
|
64
|
-
PORT_RE.lastIndex = 0;
|
|
65
|
-
let match;
|
|
66
|
-
while ((match = PORT_RE.exec(line)) !== null) {
|
|
67
|
-
const port = parseInt(match[1], 10);
|
|
68
|
-
if (port > 0 && port <= 65535 && !info.ports.has(port)) {
|
|
69
|
-
info.ports.add(port);
|
|
70
|
-
found = true;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
// Also scan the buffer (for single-line output without trailing newline)
|
|
75
|
-
PORT_RE.lastIndex = 0;
|
|
76
|
-
let match;
|
|
77
|
-
while ((match = PORT_RE.exec(info.lineBuffer)) !== null) {
|
|
78
|
-
const port = parseInt(match[1], 10);
|
|
79
|
-
if (port > 0 && port <= 65535 && !info.ports.has(port)) {
|
|
80
|
-
info.ports.add(port);
|
|
81
|
-
found = true;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (found && !this.paused) {
|
|
85
|
-
if (this.immediatePollTimer)
|
|
86
|
-
clearTimeout(this.immediatePollTimer);
|
|
87
|
-
this.immediatePollTimer = setTimeout(() => this.poll(), 500);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
removeTerminal(terminalId) {
|
|
91
|
-
const info = this.terminals.get(terminalId);
|
|
92
|
-
if (!info)
|
|
93
|
-
return;
|
|
94
|
-
this.terminals.delete(terminalId);
|
|
95
|
-
this.rebuildAndNotify();
|
|
96
|
-
}
|
|
97
|
-
removeProject(projectId) {
|
|
98
|
-
for (const [tid, info] of this.terminals) {
|
|
99
|
-
if (info.projectId === projectId) {
|
|
100
|
-
this.terminals.delete(tid);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const hadPorts = this.alivePorts.has(projectId);
|
|
104
|
-
this.alivePorts.delete(projectId);
|
|
105
|
-
if (hadPorts) {
|
|
106
|
-
this.notify();
|
|
107
|
-
}
|
|
28
|
+
return { ...this.prevPorts };
|
|
108
29
|
}
|
|
109
30
|
destroy() {
|
|
110
31
|
this.destroyed = true;
|
|
@@ -112,12 +33,6 @@ export class PortMonitor {
|
|
|
112
33
|
clearInterval(this.pollTimer);
|
|
113
34
|
this.pollTimer = null;
|
|
114
35
|
}
|
|
115
|
-
if (this.immediatePollTimer) {
|
|
116
|
-
clearTimeout(this.immediatePollTimer);
|
|
117
|
-
this.immediatePollTimer = null;
|
|
118
|
-
}
|
|
119
|
-
this.terminals.clear();
|
|
120
|
-
this.alivePorts.clear();
|
|
121
36
|
}
|
|
122
37
|
async poll() {
|
|
123
38
|
if (this.destroyed || this.polling)
|
|
@@ -126,116 +41,141 @@ export class PortMonitor {
|
|
|
126
41
|
try {
|
|
127
42
|
await this.doPoll();
|
|
128
43
|
}
|
|
44
|
+
catch {
|
|
45
|
+
// Swallow poll errors so a transient ps/lsof hiccup doesn't kill the loop
|
|
46
|
+
}
|
|
129
47
|
finally {
|
|
130
48
|
this.polling = false;
|
|
131
49
|
}
|
|
132
50
|
}
|
|
133
51
|
async doPoll() {
|
|
134
|
-
|
|
52
|
+
const ptys = this.ptyManager.getProjectPids();
|
|
53
|
+
if (ptys.length === 0) {
|
|
54
|
+
this.updateAndNotify({});
|
|
135
55
|
return;
|
|
136
|
-
// Build projectId → Set<port> from all terminals
|
|
137
|
-
const projectPorts = new Map();
|
|
138
|
-
for (const info of this.terminals.values()) {
|
|
139
|
-
if (info.ports.size === 0)
|
|
140
|
-
continue;
|
|
141
|
-
let set = projectPorts.get(info.projectId);
|
|
142
|
-
if (!set) {
|
|
143
|
-
set = new Set();
|
|
144
|
-
projectPorts.set(info.projectId, set);
|
|
145
|
-
}
|
|
146
|
-
for (const p of info.ports)
|
|
147
|
-
set.add(p);
|
|
148
|
-
}
|
|
149
|
-
// TCP-probe each unique port
|
|
150
|
-
const uniquePorts = new Set();
|
|
151
|
-
for (const set of projectPorts.values()) {
|
|
152
|
-
for (const p of set)
|
|
153
|
-
uniquePorts.add(p);
|
|
154
56
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const tryConnect = (host, fallback) => {
|
|
160
|
-
const sock = net.createConnection({ port, host }, () => {
|
|
161
|
-
probeResults.set(port, true);
|
|
162
|
-
sock.destroy();
|
|
163
|
-
resolve();
|
|
164
|
-
});
|
|
165
|
-
sock.on("error", () => {
|
|
166
|
-
if (fallback) {
|
|
167
|
-
tryConnect(fallback);
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
probeResults.set(port, false);
|
|
171
|
-
resolve();
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
sock.setTimeout(500, () => {
|
|
175
|
-
sock.destroy();
|
|
176
|
-
if (fallback) {
|
|
177
|
-
tryConnect(fallback);
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
probeResults.set(port, false);
|
|
181
|
-
resolve();
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
};
|
|
185
|
-
tryConnect("127.0.0.1", "::1");
|
|
186
|
-
})));
|
|
57
|
+
const [tree, listeners] = await Promise.all([
|
|
58
|
+
this.getProcessTree(),
|
|
59
|
+
this.getListeners(),
|
|
60
|
+
]);
|
|
187
61
|
if (this.destroyed)
|
|
188
62
|
return;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (const
|
|
193
|
-
if (
|
|
194
|
-
|
|
63
|
+
const result = {};
|
|
64
|
+
for (const { pid, projectId } of ptys) {
|
|
65
|
+
const desc = this.descendants(pid, tree);
|
|
66
|
+
for (const { pid: listenerPid, port } of listeners) {
|
|
67
|
+
if (desc.has(listenerPid)) {
|
|
68
|
+
if (!result[projectId])
|
|
69
|
+
result[projectId] = new Set();
|
|
70
|
+
result[projectId].add(port);
|
|
195
71
|
}
|
|
196
72
|
}
|
|
197
73
|
}
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
const alive = new Set();
|
|
202
|
-
for (const p of ports) {
|
|
203
|
-
if (probeResults.get(p))
|
|
204
|
-
alive.add(p);
|
|
205
|
-
}
|
|
206
|
-
if (alive.size > 0) {
|
|
207
|
-
newAlive.set(projectId, alive);
|
|
208
|
-
}
|
|
74
|
+
const final = {};
|
|
75
|
+
for (const [projectId, set] of Object.entries(result)) {
|
|
76
|
+
final[projectId] = [...set].sort((a, b) => a - b);
|
|
209
77
|
}
|
|
210
|
-
|
|
211
|
-
if (this.setsEqual(newAlive))
|
|
212
|
-
return;
|
|
213
|
-
this.alivePorts = newAlive;
|
|
214
|
-
this.notify();
|
|
78
|
+
this.updateAndNotify(final);
|
|
215
79
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
80
|
+
updateAndNotify(next) {
|
|
81
|
+
if (this.portsEqual(next, this.prevPorts))
|
|
82
|
+
return;
|
|
83
|
+
this.prevPorts = next;
|
|
84
|
+
this.onChange(next);
|
|
219
85
|
}
|
|
220
|
-
|
|
221
|
-
|
|
86
|
+
portsEqual(a, b) {
|
|
87
|
+
const aKeys = Object.keys(a);
|
|
88
|
+
const bKeys = Object.keys(b);
|
|
89
|
+
if (aKeys.length !== bKeys.length)
|
|
222
90
|
return false;
|
|
223
|
-
for (const
|
|
224
|
-
const
|
|
225
|
-
|
|
91
|
+
for (const key of aKeys) {
|
|
92
|
+
const av = a[key];
|
|
93
|
+
const bv = b[key];
|
|
94
|
+
if (!bv || av.length !== bv.length)
|
|
226
95
|
return false;
|
|
227
|
-
for (
|
|
228
|
-
if (
|
|
96
|
+
for (let i = 0; i < av.length; i++) {
|
|
97
|
+
if (av[i] !== bv[i])
|
|
229
98
|
return false;
|
|
230
99
|
}
|
|
231
100
|
}
|
|
232
101
|
return true;
|
|
233
102
|
}
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
103
|
+
async getProcessTree() {
|
|
104
|
+
const { stdout } = await execFileP('ps', ['-A', '-o', 'pid=,ppid=']);
|
|
105
|
+
const children = new Map();
|
|
106
|
+
for (const line of stdout.split('\n')) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (!trimmed)
|
|
109
|
+
continue;
|
|
110
|
+
const match = trimmed.match(/^(\d+)\s+(\d+)$/);
|
|
111
|
+
if (!match)
|
|
112
|
+
continue;
|
|
113
|
+
const pid = parseInt(match[1], 10);
|
|
114
|
+
const ppid = parseInt(match[2], 10);
|
|
115
|
+
const list = children.get(ppid);
|
|
116
|
+
if (list)
|
|
117
|
+
list.push(pid);
|
|
118
|
+
else
|
|
119
|
+
children.set(ppid, [pid]);
|
|
120
|
+
}
|
|
121
|
+
return children;
|
|
122
|
+
}
|
|
123
|
+
descendants(root, tree) {
|
|
124
|
+
const result = new Set([root]);
|
|
125
|
+
const stack = [root];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
const pid = stack.pop();
|
|
128
|
+
const kids = tree.get(pid);
|
|
129
|
+
if (!kids)
|
|
130
|
+
continue;
|
|
131
|
+
for (const kid of kids) {
|
|
132
|
+
if (!result.has(kid)) {
|
|
133
|
+
result.add(kid);
|
|
134
|
+
stack.push(kid);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
async getListeners() {
|
|
141
|
+
// lsof exits non-zero when there are no matches — read stdout from the error anyway
|
|
142
|
+
let stdout = '';
|
|
143
|
+
try {
|
|
144
|
+
const result = await execFileP('lsof', [
|
|
145
|
+
'-nP',
|
|
146
|
+
'-iTCP',
|
|
147
|
+
'-sTCP:LISTEN',
|
|
148
|
+
'-F',
|
|
149
|
+
'pn',
|
|
150
|
+
]);
|
|
151
|
+
stdout = result.stdout;
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
const err = e;
|
|
155
|
+
stdout = err.stdout ?? '';
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
let currentPid = null;
|
|
159
|
+
for (const line of stdout.split('\n')) {
|
|
160
|
+
if (line.length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
const tag = line[0];
|
|
163
|
+
const value = line.slice(1);
|
|
164
|
+
if (tag === 'p') {
|
|
165
|
+
const pid = parseInt(value, 10);
|
|
166
|
+
currentPid = Number.isFinite(pid) ? pid : null;
|
|
167
|
+
}
|
|
168
|
+
else if (tag === 'n' && currentPid !== null) {
|
|
169
|
+
// Address forms: *:3000, 127.0.0.1:3000, [::1]:3000
|
|
170
|
+
const portMatch = value.match(/:(\d+)$/);
|
|
171
|
+
if (!portMatch)
|
|
172
|
+
continue;
|
|
173
|
+
const port = parseInt(portMatch[1], 10);
|
|
174
|
+
if (port > 0 && port <= 65535) {
|
|
175
|
+
out.push({ pid: currentPid, port });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
238
178
|
}
|
|
239
|
-
|
|
179
|
+
return out;
|
|
240
180
|
}
|
|
241
181
|
}
|
|
@@ -73,6 +73,19 @@ export class PtyManager {
|
|
|
73
73
|
terminalExists(terminalId) {
|
|
74
74
|
return this.sessions.has(terminalId);
|
|
75
75
|
}
|
|
76
|
+
/** Returns {pid, projectId} for every active PTY. Used to attribute listening sockets to projects. */
|
|
77
|
+
getProjectPids() {
|
|
78
|
+
const result = [];
|
|
79
|
+
for (const managed of this.sessions.values()) {
|
|
80
|
+
try {
|
|
81
|
+
result.push({ pid: managed.process.pid, projectId: managed.projectId });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// PTY may have been killed
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
76
89
|
/** Returns projectId → terminalIds[] for terminals running an AI coding agent. */
|
|
77
90
|
getAgentProjects() {
|
|
78
91
|
const result = new Map();
|
|
@@ -22,7 +22,7 @@ export class WsHandler {
|
|
|
22
22
|
this.ptyManager = ptyManager;
|
|
23
23
|
this.projectStore = projectStore;
|
|
24
24
|
this.onIdle = options?.onIdle;
|
|
25
|
-
this.portMonitor = new PortMonitor((ports) => {
|
|
25
|
+
this.portMonitor = new PortMonitor(ptyManager, (ports) => {
|
|
26
26
|
this.send({ type: 'port:status', ports });
|
|
27
27
|
});
|
|
28
28
|
this.claudeMonitor = new ClaudeMonitor(ptyManager, (statuses) => {
|
|
@@ -135,7 +135,6 @@ export class WsHandler {
|
|
|
135
135
|
this.ptyManager.resize(msg.terminalId, msg.cols, msg.rows);
|
|
136
136
|
break;
|
|
137
137
|
case 'pty:kill': {
|
|
138
|
-
this.portMonitor.removeTerminal(msg.terminalId);
|
|
139
138
|
this.claudeMonitor.removeTerminal(msg.terminalId);
|
|
140
139
|
const projectId = this.ptyManager.kill(msg.terminalId);
|
|
141
140
|
if (projectId) {
|
|
@@ -145,7 +144,6 @@ export class WsHandler {
|
|
|
145
144
|
break;
|
|
146
145
|
}
|
|
147
146
|
case 'project:kill': {
|
|
148
|
-
this.portMonitor.removeProject(msg.projectId);
|
|
149
147
|
const killed = this.ptyManager.killProject(msg.projectId);
|
|
150
148
|
for (const tid of killed) {
|
|
151
149
|
this.send({ type: 'pty:exit', terminalId: tid, exitCode: 0 });
|
|
@@ -158,7 +156,6 @@ export class WsHandler {
|
|
|
158
156
|
break;
|
|
159
157
|
}
|
|
160
158
|
case 'project:remove': {
|
|
161
|
-
this.portMonitor.removeProject(msg.projectId);
|
|
162
159
|
const killed = this.ptyManager.killProject(msg.projectId);
|
|
163
160
|
for (const tid of killed) {
|
|
164
161
|
this.send({ type: 'pty:exit', terminalId: tid, exitCode: 0 });
|
|
@@ -223,10 +220,8 @@ export class WsHandler {
|
|
|
223
220
|
try {
|
|
224
221
|
this.ptyManager.spawn(terminalId, projectId, cwd, (tid, data) => {
|
|
225
222
|
this.send({ type: 'pty:output', terminalId: tid, data });
|
|
226
|
-
this.portMonitor.scanOutput(tid, projectId, data);
|
|
227
223
|
this.claudeMonitor.recordOutput(tid);
|
|
228
224
|
}, (tid, exitCode) => {
|
|
229
|
-
this.portMonitor.removeTerminal(tid);
|
|
230
225
|
this.claudeMonitor.removeTerminal(tid);
|
|
231
226
|
this.send({ type: 'pty:exit', terminalId: tid, exitCode });
|
|
232
227
|
});
|
package/package.json
CHANGED