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 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 net from "node:net";
2
- // Strip ANSI escape codes from PTY output
3
- const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\x1b[()][0-9A-B]/g;
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
- terminals = new Map();
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
- paused = true;
15
- constructor(onChange) {
11
+ constructor(ptyManager, onChange) {
12
+ this.ptyManager = ptyManager;
16
13
  this.onChange = onChange;
17
14
  }
18
15
  resume() {
19
- if (this.destroyed || !this.paused)
16
+ if (this.destroyed || this.pollTimer)
20
17
  return;
21
- this.paused = false;
22
- this.pollTimer = setInterval(() => this.poll(), 10_000);
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
- const result = {};
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
- if (this.destroyed)
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 probeResults = new Map();
156
- await Promise.all([...uniquePorts].map((port) => new Promise((resolve) => {
157
- // Try IPv4 first, fall back to IPv6 (::1).
158
- // Many dev servers (Angular/Vite) bind to ::1 on macOS.
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
- // Scrub dead ports from terminal tracking so stale entries
190
- // don't cause overlap when another project reuses the same port
191
- for (const info of this.terminals.values()) {
192
- for (const p of info.ports) {
193
- if (!probeResults.get(p)) {
194
- info.ports.delete(p);
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
- // Build new alive state
199
- const newAlive = new Map();
200
- for (const [projectId, ports] of projectPorts) {
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
- // Check if changed
211
- if (this.setsEqual(newAlive))
212
- return;
213
- this.alivePorts = newAlive;
214
- this.notify();
78
+ this.updateAndNotify(final);
215
79
  }
216
- rebuildAndNotify() {
217
- // After terminal removal, re-poll immediately to update state
218
- this.poll();
80
+ updateAndNotify(next) {
81
+ if (this.portsEqual(next, this.prevPorts))
82
+ return;
83
+ this.prevPorts = next;
84
+ this.onChange(next);
219
85
  }
220
- setsEqual(newAlive) {
221
- if (newAlive.size !== this.alivePorts.size)
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 [pid, ports] of newAlive) {
224
- const existing = this.alivePorts.get(pid);
225
- if (!existing || existing.size !== ports.size)
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 (const p of ports) {
228
- if (!existing.has(p))
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
- notify() {
235
- const result = {};
236
- for (const [pid, ports] of this.alivePorts) {
237
- result[pid] = [...ports];
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
- this.onChange(result);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paneful",
3
- "version": "0.9.16",
3
+ "version": "0.9.17",
4
4
  "description": "A fast, GPU-accelerated terminal manager with split panes, project organization, editor sync, and AI agent detection. Native macOS app or browser.",
5
5
  "type": "module",
6
6
  "bin": {