paneful 0.9.16 → 0.9.18

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.
@@ -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();
@@ -6,6 +6,8 @@ const DEFAULTS = {
6
6
  theme: 'system',
7
7
  sidebarWidth: 224,
8
8
  editorSyncEnabled: true,
9
+ sourceControlOpen: false,
10
+ sourceControlWidth: 360,
9
11
  },
10
12
  activeProjectId: null,
11
13
  };
@@ -24,6 +26,8 @@ export class SettingsStore {
24
26
  theme: raw.ui?.theme ?? DEFAULTS.ui.theme,
25
27
  sidebarWidth: raw.ui?.sidebarWidth ?? DEFAULTS.ui.sidebarWidth,
26
28
  editorSyncEnabled: raw.ui?.editorSyncEnabled ?? DEFAULTS.ui.editorSyncEnabled,
29
+ sourceControlOpen: raw.ui?.sourceControlOpen ?? DEFAULTS.ui.sourceControlOpen,
30
+ sourceControlWidth: raw.ui?.sourceControlWidth ?? DEFAULTS.ui.sourceControlWidth,
27
31
  },
28
32
  activeProjectId: raw.activeProjectId ?? DEFAULTS.activeProjectId,
29
33
  };
@@ -4,6 +4,7 @@ import { newProject } from './project-store.js';
4
4
  import { PortMonitor } from './port-monitor.js';
5
5
  import { ClaudeMonitor } from './claude-monitor.js';
6
6
  import { GitMonitor } from './git-monitor.js';
7
+ import { GitSourceControl, } from './git-source-control.js';
7
8
  import { EditorMonitor } from './editor-monitor.js';
8
9
  import { InboxMonitor } from './inbox-monitor.js';
9
10
  export class WsHandler {
@@ -14,6 +15,7 @@ export class WsHandler {
14
15
  portMonitor;
15
16
  claudeMonitor;
16
17
  gitMonitor;
18
+ sourceControl;
17
19
  editorMonitor;
18
20
  inboxMonitor;
19
21
  idleTimer = null;
@@ -22,7 +24,7 @@ export class WsHandler {
22
24
  this.ptyManager = ptyManager;
23
25
  this.projectStore = projectStore;
24
26
  this.onIdle = options?.onIdle;
25
- this.portMonitor = new PortMonitor((ports) => {
27
+ this.portMonitor = new PortMonitor(ptyManager, (ports) => {
26
28
  this.send({ type: 'port:status', ports });
27
29
  });
28
30
  this.claudeMonitor = new ClaudeMonitor(ptyManager, (statuses) => {
@@ -31,6 +33,9 @@ export class WsHandler {
31
33
  this.gitMonitor = new GitMonitor(projectStore, (branches) => {
32
34
  this.send({ type: 'git:branch', branches });
33
35
  });
36
+ this.sourceControl = new GitSourceControl(projectStore, (projectId, status) => {
37
+ this.send({ type: 'sc:status', projectId, status });
38
+ });
34
39
  this.editorMonitor = new EditorMonitor((projectName) => {
35
40
  this.send({ type: 'editor:active', projectName });
36
41
  }, (needsAccessibility) => {
@@ -135,7 +140,6 @@ export class WsHandler {
135
140
  this.ptyManager.resize(msg.terminalId, msg.cols, msg.rows);
136
141
  break;
137
142
  case 'pty:kill': {
138
- this.portMonitor.removeTerminal(msg.terminalId);
139
143
  this.claudeMonitor.removeTerminal(msg.terminalId);
140
144
  const projectId = this.ptyManager.kill(msg.terminalId);
141
145
  if (projectId) {
@@ -145,7 +149,6 @@ export class WsHandler {
145
149
  break;
146
150
  }
147
151
  case 'project:kill': {
148
- this.portMonitor.removeProject(msg.projectId);
149
152
  const killed = this.ptyManager.killProject(msg.projectId);
150
153
  for (const tid of killed) {
151
154
  this.send({ type: 'pty:exit', terminalId: tid, exitCode: 0 });
@@ -158,7 +161,6 @@ export class WsHandler {
158
161
  break;
159
162
  }
160
163
  case 'project:remove': {
161
- this.portMonitor.removeProject(msg.projectId);
162
164
  const killed = this.ptyManager.killProject(msg.projectId);
163
165
  for (const tid of killed) {
164
166
  this.send({ type: 'pty:exit', terminalId: tid, exitCode: 0 });
@@ -175,6 +177,22 @@ export class WsHandler {
175
177
  }
176
178
  break;
177
179
  }
180
+ case 'open:file': {
181
+ const project = this.projectStore.list().find((p) => p.id === msg.projectId);
182
+ if (!project)
183
+ break;
184
+ // Resolve and guard against path traversal
185
+ import('node:path').then((path) => {
186
+ const abs = path.resolve(project.cwd, msg.path);
187
+ const projectRoot = path.resolve(project.cwd);
188
+ if (abs !== projectRoot && !abs.startsWith(projectRoot + path.sep))
189
+ return;
190
+ import('open').then(({ default: open }) => {
191
+ open(abs).catch(() => { });
192
+ });
193
+ });
194
+ break;
195
+ }
178
196
  case 'editor:sync': {
179
197
  if (msg.enabled) {
180
198
  this.editorMonitor.resume();
@@ -193,6 +211,97 @@ export class WsHandler {
193
211
  }
194
212
  break;
195
213
  }
214
+ case 'sc:set-active': {
215
+ this.sourceControl.setActive(msg.projectId);
216
+ break;
217
+ }
218
+ case 'sc:diff:request': {
219
+ const { projectId, file, kind } = msg;
220
+ this.sourceControl.requestDiff(projectId, file, kind).then((result) => {
221
+ if (!result)
222
+ return;
223
+ this.send({
224
+ type: 'sc:diff',
225
+ projectId,
226
+ file,
227
+ kind,
228
+ diff: result.diff,
229
+ binary: result.binary,
230
+ truncated: result.truncated,
231
+ });
232
+ });
233
+ break;
234
+ }
235
+ case 'sc:stage': {
236
+ const { projectId, files } = msg;
237
+ this.sourceControl.stage(projectId, files).then((res) => {
238
+ this.send({ type: 'sc:action:result', projectId, action: 'stage', ok: res.ok, error: res.error });
239
+ });
240
+ break;
241
+ }
242
+ case 'sc:unstage': {
243
+ const { projectId, files } = msg;
244
+ this.sourceControl.unstage(projectId, files).then((res) => {
245
+ this.send({ type: 'sc:action:result', projectId, action: 'unstage', ok: res.ok, error: res.error });
246
+ });
247
+ break;
248
+ }
249
+ case 'sc:discard': {
250
+ const { projectId, trackedFiles, untrackedFiles } = msg;
251
+ this.sourceControl.discard(projectId, trackedFiles, untrackedFiles).then((res) => {
252
+ this.send({ type: 'sc:action:result', projectId, action: 'discard', ok: res.ok, error: res.error });
253
+ });
254
+ break;
255
+ }
256
+ case 'sc:commit': {
257
+ const { projectId, message } = msg;
258
+ this.sourceControl.commit(projectId, message).then((res) => {
259
+ this.send({ type: 'sc:action:result', projectId, action: 'commit', ok: res.ok, error: res.error });
260
+ });
261
+ break;
262
+ }
263
+ case 'sc:push': {
264
+ const { projectId } = msg;
265
+ this.sourceControl.push(projectId).then((res) => {
266
+ this.send({ type: 'sc:action:result', projectId, action: 'push', ok: res.ok, error: res.error });
267
+ });
268
+ break;
269
+ }
270
+ case 'sc:pull': {
271
+ const { projectId } = msg;
272
+ this.sourceControl.pull(projectId).then((res) => {
273
+ this.send({ type: 'sc:action:result', projectId, action: 'pull', ok: res.ok, error: res.error });
274
+ });
275
+ break;
276
+ }
277
+ case 'sc:stash:create': {
278
+ const { projectId, message } = msg;
279
+ this.sourceControl.stashCreate(projectId, message).then((res) => {
280
+ this.send({ type: 'sc:action:result', projectId, action: 'stash:create', ok: res.ok, error: res.error });
281
+ });
282
+ break;
283
+ }
284
+ case 'sc:stash:pop': {
285
+ const { projectId, index } = msg;
286
+ this.sourceControl.stashPop(projectId, index).then((res) => {
287
+ this.send({ type: 'sc:action:result', projectId, action: 'stash:pop', ok: res.ok, error: res.error });
288
+ });
289
+ break;
290
+ }
291
+ case 'sc:stash:apply': {
292
+ const { projectId, index } = msg;
293
+ this.sourceControl.stashApply(projectId, index).then((res) => {
294
+ this.send({ type: 'sc:action:result', projectId, action: 'stash:apply', ok: res.ok, error: res.error });
295
+ });
296
+ break;
297
+ }
298
+ case 'sc:stash:drop': {
299
+ const { projectId, index } = msg;
300
+ this.sourceControl.stashDrop(projectId, index).then((res) => {
301
+ this.send({ type: 'sc:action:result', projectId, action: 'stash:drop', ok: res.ok, error: res.error });
302
+ });
303
+ break;
304
+ }
196
305
  }
197
306
  }
198
307
  getEditorState() {
@@ -202,6 +311,7 @@ export class WsHandler {
202
311
  this.portMonitor.resume();
203
312
  this.claudeMonitor.resume();
204
313
  this.gitMonitor.resume();
314
+ this.sourceControl.resume();
205
315
  // Editor monitor is started on-demand via editor:sync message
206
316
  this.inboxMonitor.resume();
207
317
  }
@@ -209,6 +319,7 @@ export class WsHandler {
209
319
  this.portMonitor.pause();
210
320
  this.claudeMonitor.pause();
211
321
  this.gitMonitor.pause();
322
+ this.sourceControl.pause();
212
323
  this.editorMonitor.pause();
213
324
  this.inboxMonitor.pause();
214
325
  }
@@ -216,6 +327,7 @@ export class WsHandler {
216
327
  this.portMonitor.destroy();
217
328
  this.claudeMonitor.destroy();
218
329
  this.gitMonitor.destroy();
330
+ this.sourceControl.destroy();
219
331
  this.editorMonitor.destroy();
220
332
  this.inboxMonitor.destroy();
221
333
  }
@@ -223,10 +335,8 @@ export class WsHandler {
223
335
  try {
224
336
  this.ptyManager.spawn(terminalId, projectId, cwd, (tid, data) => {
225
337
  this.send({ type: 'pty:output', terminalId: tid, data });
226
- this.portMonitor.scanOutput(tid, projectId, data);
227
338
  this.claudeMonitor.recordOutput(tid);
228
339
  }, (tid, exitCode) => {
229
- this.portMonitor.removeTerminal(tid);
230
340
  this.claudeMonitor.removeTerminal(tid);
231
341
  this.send({ type: 'pty:exit', terminalId: tid, exitCode });
232
342
  });