paneful 0.9.1 → 0.9.2

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.
@@ -11,9 +11,24 @@ export class ClaudeMonitor {
11
11
  this.ptyManager = ptyManager;
12
12
  this.onChange = onChange;
13
13
  }
14
- start() {
14
+ resume() {
15
+ if (this.destroyed || this.pollTimer)
16
+ return;
15
17
  this.pollTimer = setInterval(() => this.poll(), 3000);
16
18
  }
19
+ pause() {
20
+ if (this.pollTimer) {
21
+ clearInterval(this.pollTimer);
22
+ this.pollTimer = null;
23
+ }
24
+ }
25
+ getStatuses() {
26
+ return { ...this.prevStatuses };
27
+ }
28
+ /** @deprecated Use resume() instead */
29
+ start() {
30
+ this.resume();
31
+ }
17
32
  /** Call from the PTY output path to record activity. */
18
33
  recordOutput(terminalId) {
19
34
  this.lastOutput.set(terminalId, Date.now());
@@ -0,0 +1,394 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ const EDITOR_PATTERNS = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
7
+ // Long-lived osascript fallback — same as before.
8
+ const STREAMING_SCRIPT = `
9
+ repeat
10
+ try
11
+ tell application "System Events"
12
+ set frontApp to name of first application process whose frontmost is true
13
+ set winTitle to ""
14
+ tell process frontApp
15
+ if exists front window then
16
+ set winTitle to name of front window
17
+ end if
18
+ end tell
19
+ end tell
20
+ log frontApp & "\\t" & winTitle
21
+ end try
22
+ delay 0.75
23
+ end repeat
24
+ `;
25
+ // Swift helper: event-driven app switches via NSWorkspace, AX observer for window/title changes.
26
+ const SWIFT_SOURCE = `
27
+ import Cocoa
28
+
29
+ let editorPatterns: [String] = ["cursor", "code", "vscode", "visual studio code", "zed", "windsurf", "electron"]
30
+
31
+ func isEditor(_ name: String) -> Bool {
32
+ let lower = name.lowercased()
33
+ return editorPatterns.contains { lower.contains($0) }
34
+ }
35
+
36
+ func getWindowTitle(pid: pid_t) -> String {
37
+ let app = AXUIElementCreateApplication(pid)
38
+ var value: AnyObject?
39
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &value) == .success else {
40
+ return ""
41
+ }
42
+ var title: AnyObject?
43
+ guard AXUIElementCopyAttributeValue(value as! AXUIElement, kAXTitleAttribute as CFString, &title) == .success else {
44
+ return ""
45
+ }
46
+ return title as? String ?? ""
47
+ }
48
+
49
+ setbuf(stdout, nil)
50
+ setbuf(stderr, nil)
51
+
52
+ let startTime = CFAbsoluteTimeGetCurrent()
53
+ func log(_ msg: String) {
54
+ let elapsed = String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime)
55
+ fputs("[swift +\\(elapsed)s] \\(msg)\\n", stderr)
56
+ }
57
+
58
+ var lastOutput = ""
59
+ var currentPid: pid_t = 0
60
+ var currentAppName = ""
61
+ var axObserver: AXObserver?
62
+ var observedWindow: AXUIElement?
63
+ var fallbackTimer: Timer?
64
+
65
+ func emit(_ appName: String, _ title: String, source: String) {
66
+ let output = "\\(appName)\\t\\(title)"
67
+ guard output != lastOutput else { return }
68
+ lastOutput = output
69
+ log("emit (\\(source)): \\(appName) | \\(title.isEmpty ? "(no title)" : title)")
70
+ print(output)
71
+ }
72
+
73
+ func emitCurrentTitle(source: String) {
74
+ let title = getWindowTitle(pid: currentPid)
75
+ emit(currentAppName, title, source: source)
76
+ }
77
+
78
+ let axCallback: AXObserverCallback = { _, _, notification, _ in
79
+ let name = notification as String
80
+ if name == (kAXFocusedWindowChangedNotification as String) {
81
+ log("AX: focused window changed")
82
+ observeFocusedWindowTitle()
83
+ emitCurrentTitle(source: "ax-window-change")
84
+ } else if name == (kAXTitleChangedNotification as String) {
85
+ log("AX: title changed")
86
+ emitCurrentTitle(source: "ax-title-change")
87
+ } else {
88
+ emitCurrentTitle(source: "ax-\\(name)")
89
+ }
90
+ }
91
+
92
+ func observeFocusedWindowTitle() {
93
+ guard let obs = axObserver else { return }
94
+ if let old = observedWindow {
95
+ AXObserverRemoveNotification(obs, old, kAXTitleChangedNotification as CFString)
96
+ observedWindow = nil
97
+ }
98
+ let app = AXUIElementCreateApplication(currentPid)
99
+ var winValue: AnyObject?
100
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &winValue) == .success else { return }
101
+ let window = winValue as! AXUIElement
102
+ AXObserverAddNotification(obs, window, kAXTitleChangedNotification as CFString, nil)
103
+ observedWindow = window
104
+ }
105
+
106
+ func startObserving(pid: pid_t) {
107
+ stopObserving()
108
+ emitCurrentTitle(source: "editor-activated")
109
+
110
+ var observer: AXObserver?
111
+ guard AXObserverCreate(pid, axCallback, &observer) == .success, let obs = observer else {
112
+ log("AXObserver failed for pid \\(pid), using poll fallback")
113
+ fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
114
+ emitCurrentTitle(source: "poll")
115
+ }
116
+ return
117
+ }
118
+
119
+ let appElement = AXUIElementCreateApplication(pid)
120
+ AXObserverAddNotification(obs, appElement, kAXFocusedWindowChangedNotification as CFString, nil)
121
+ CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
122
+ axObserver = obs
123
+ observeFocusedWindowTitle()
124
+ log("AXObserver active for pid \\(pid)")
125
+
126
+ // AX notifications don't fire for Electron apps (Cursor, VS Code, etc.)
127
+ // so this timer is the primary mechanism for those editors
128
+ fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
129
+ emitCurrentTitle(source: "poll")
130
+ }
131
+ }
132
+
133
+ func stopObserving() {
134
+ if let obs = axObserver {
135
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
136
+ axObserver = nil
137
+ observedWindow = nil
138
+ }
139
+ fallbackTimer?.invalidate()
140
+ fallbackTimer = nil
141
+ }
142
+
143
+ let nc = NSWorkspace.shared.notificationCenter
144
+ nc.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { notif in
145
+ guard let app = notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
146
+ let name = app.localizedName else { return }
147
+ log("app switch: \\(name)")
148
+ currentAppName = name
149
+ currentPid = app.processIdentifier
150
+ if isEditor(name) {
151
+ startObserving(pid: currentPid)
152
+ } else {
153
+ stopObserving()
154
+ emit(name, "", source: "app-switch")
155
+ }
156
+ }
157
+
158
+ if let front = NSWorkspace.shared.frontmostApplication, let name = front.localizedName {
159
+ log("seed: \\(name)")
160
+ currentAppName = name
161
+ currentPid = front.processIdentifier
162
+ if isEditor(name) {
163
+ startObserving(pid: currentPid)
164
+ } else {
165
+ emit(name, "", source: "seed")
166
+ }
167
+ }
168
+
169
+ RunLoop.main.run()
170
+ `;
171
+ export class EditorMonitor {
172
+ onChange;
173
+ proc = null;
174
+ destroyed = false;
175
+ lineBuffer = '';
176
+ cache = { projectName: null };
177
+ restartTimer = null;
178
+ mode = 'osascript';
179
+ compiling = false;
180
+ constructor(onChange) {
181
+ this.onChange = onChange;
182
+ }
183
+ getState() {
184
+ return this.cache;
185
+ }
186
+ get helperPath() {
187
+ return join(homedir(), '.paneful', 'paneful-editor-helper');
188
+ }
189
+ get versionPath() {
190
+ return this.helperPath + '.version';
191
+ }
192
+ sourceHash() {
193
+ return createHash('sha256').update(SWIFT_SOURCE).digest('hex').slice(0, 16);
194
+ }
195
+ isHelperCurrent() {
196
+ if (!existsSync(this.helperPath))
197
+ return false;
198
+ try {
199
+ const stored = readFileSync(this.versionPath, 'utf-8').trim();
200
+ return stored === this.sourceHash();
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ async compileHelper() {
207
+ if (this.compiling)
208
+ return;
209
+ this.compiling = true;
210
+ const dir = join(homedir(), '.paneful');
211
+ mkdirSync(dir, { recursive: true });
212
+ const tmpSrc = join(tmpdir(), `paneful-editor-helper-${process.pid}.swift`);
213
+ writeFileSync(tmpSrc, SWIFT_SOURCE);
214
+ try {
215
+ await new Promise((resolve, reject) => {
216
+ const child = execFile('swiftc', [tmpSrc, '-o', this.helperPath, '-framework', 'Cocoa', '-O'], { timeout: 60_000 }, (err) => (err ? reject(err) : resolve()));
217
+ child.unref();
218
+ });
219
+ writeFileSync(this.versionPath, this.sourceHash());
220
+ }
221
+ finally {
222
+ this.compiling = false;
223
+ try {
224
+ unlinkSync(tmpSrc);
225
+ }
226
+ catch { }
227
+ }
228
+ }
229
+ resume() {
230
+ if (this.destroyed || this.proc)
231
+ return;
232
+ if (process.platform !== 'darwin')
233
+ return;
234
+ if (this.isHelperCurrent()) {
235
+ this.mode = 'native';
236
+ console.log('[editor-monitor] native helper is current, starting directly');
237
+ this.startProcess();
238
+ }
239
+ else {
240
+ this.mode = 'osascript';
241
+ console.log('[editor-monitor] native helper not found, starting osascript fallback');
242
+ this.startProcess();
243
+ console.log('[editor-monitor] compiling native helper in background...');
244
+ this.compileHelper()
245
+ .then(() => {
246
+ if (this.destroyed)
247
+ return;
248
+ console.log('[editor-monitor] compilation done, hot-swapping to native helper');
249
+ this.stopProcess();
250
+ this.mode = 'native';
251
+ this.startProcess();
252
+ })
253
+ .catch((err) => {
254
+ console.log(`[editor-monitor] compilation failed, keeping osascript: ${err.message}`);
255
+ });
256
+ }
257
+ }
258
+ pause() {
259
+ this.stopProcess();
260
+ }
261
+ destroy() {
262
+ this.destroyed = true;
263
+ this.stopProcess();
264
+ }
265
+ startProcess() {
266
+ if (this.destroyed || this.proc)
267
+ return;
268
+ let proc;
269
+ if (this.mode === 'native') {
270
+ proc = spawn(this.helperPath, [], {
271
+ stdio: ['ignore', 'pipe', 'pipe'],
272
+ });
273
+ this.proc = proc;
274
+ this.lineBuffer = '';
275
+ proc.stdout.on('data', (chunk) => {
276
+ if (this.destroyed)
277
+ return;
278
+ this.feedData(chunk);
279
+ });
280
+ // Forward Swift helper's debug logs to server console
281
+ let stderrBuf = '';
282
+ proc.stderr.on('data', (chunk) => {
283
+ stderrBuf += chunk.toString();
284
+ const lines = stderrBuf.split('\n');
285
+ stderrBuf = lines.pop() ?? '';
286
+ for (const line of lines) {
287
+ if (line.trim())
288
+ console.log(`[editor-monitor] ${line.trim()}`);
289
+ }
290
+ });
291
+ }
292
+ else {
293
+ proc = spawn('osascript', ['-e', STREAMING_SCRIPT], {
294
+ stdio: ['ignore', 'ignore', 'pipe'],
295
+ });
296
+ this.proc = proc;
297
+ this.lineBuffer = '';
298
+ proc.stderr.on('data', (chunk) => {
299
+ if (this.destroyed)
300
+ return;
301
+ this.feedData(chunk);
302
+ });
303
+ }
304
+ const currentMode = this.mode;
305
+ proc.on('error', (err) => {
306
+ if (this.proc !== proc)
307
+ return;
308
+ const msg = err.message || '';
309
+ const needsAccess = msg.includes('not allowed assistive access') || msg.includes('1719');
310
+ this.cache = { projectName: null, needsAccessibility: needsAccess || undefined };
311
+ this.proc = null;
312
+ if (currentMode === 'native') {
313
+ console.log(`[editor-monitor] native helper error, falling back to osascript: ${msg}`);
314
+ this.mode = 'osascript';
315
+ }
316
+ this.scheduleRestart();
317
+ });
318
+ proc.on('exit', (code) => {
319
+ if (this.proc !== proc)
320
+ return;
321
+ this.proc = null;
322
+ if (currentMode === 'native' && code !== 0) {
323
+ console.log(`[editor-monitor] native helper exited (code ${code}), falling back to osascript`);
324
+ this.mode = 'osascript';
325
+ }
326
+ this.scheduleRestart();
327
+ });
328
+ }
329
+ feedData(chunk) {
330
+ this.lineBuffer += chunk.toString();
331
+ const lines = this.lineBuffer.split('\n');
332
+ this.lineBuffer = lines.pop() ?? '';
333
+ for (const line of lines) {
334
+ this.handleLine(line.trim());
335
+ }
336
+ }
337
+ stopProcess() {
338
+ if (this.restartTimer) {
339
+ clearTimeout(this.restartTimer);
340
+ this.restartTimer = null;
341
+ }
342
+ if (this.proc) {
343
+ this.proc.kill();
344
+ this.proc = null;
345
+ }
346
+ }
347
+ scheduleRestart() {
348
+ if (this.destroyed)
349
+ return;
350
+ if (this.restartTimer)
351
+ return;
352
+ this.restartTimer = setTimeout(() => {
353
+ this.restartTimer = null;
354
+ if (!this.destroyed && !this.proc) {
355
+ this.startProcess();
356
+ }
357
+ }, 5000);
358
+ }
359
+ handleLine(line) {
360
+ if (!line)
361
+ return;
362
+ const tabIdx = line.indexOf('\t');
363
+ const appName = tabIdx >= 0 ? line.slice(0, tabIdx) : line;
364
+ const title = tabIdx >= 0 ? line.slice(tabIdx + 1) : '';
365
+ const isEditor = EDITOR_PATTERNS.some((pat) => appName.toLowerCase().includes(pat));
366
+ if (!isEditor || !title) {
367
+ this.cache = { projectName: null };
368
+ return;
369
+ }
370
+ let projectName = null;
371
+ const pathMatch = title.match(/^(~?\/[^\s]+)/);
372
+ if (pathMatch) {
373
+ const segments = pathMatch[1].replace(/\/$/, '').split('/');
374
+ projectName = segments[segments.length - 1] || null;
375
+ }
376
+ if (!projectName) {
377
+ const parts = title.split(' \u2014 ');
378
+ if (parts.length >= 3) {
379
+ projectName = parts[parts.length - 2];
380
+ }
381
+ else if (parts.length === 2) {
382
+ projectName = parts[0];
383
+ }
384
+ else {
385
+ projectName = title;
386
+ }
387
+ }
388
+ const prev = this.cache.projectName;
389
+ this.cache = { projectName };
390
+ if (projectName && projectName !== prev) {
391
+ this.onChange(projectName);
392
+ }
393
+ }
394
+ }
@@ -5,12 +5,25 @@ export class GitMonitor {
5
5
  branches = new Map();
6
6
  pollTimer = null;
7
7
  destroyed = false;
8
+ polling = false;
8
9
  constructor(projectStore, onChange) {
9
10
  this.projectStore = projectStore;
10
11
  this.onChange = onChange;
12
+ // Initial poll to have data ready for first client connection
13
+ this.poll();
14
+ }
15
+ resume() {
16
+ if (this.destroyed || this.pollTimer)
17
+ return;
11
18
  this.poll();
12
19
  this.pollTimer = setInterval(() => this.poll(), 10_000);
13
20
  }
21
+ pause() {
22
+ if (this.pollTimer) {
23
+ clearInterval(this.pollTimer);
24
+ this.pollTimer = null;
25
+ }
26
+ }
14
27
  getBranches() {
15
28
  const result = {};
16
29
  for (const [id, branch] of this.branches) {
@@ -27,6 +40,17 @@ export class GitMonitor {
27
40
  this.branches.clear();
28
41
  }
29
42
  async poll() {
43
+ if (this.destroyed || this.polling)
44
+ return;
45
+ this.polling = true;
46
+ try {
47
+ await this.doPoll();
48
+ }
49
+ finally {
50
+ this.polling = false;
51
+ }
52
+ }
53
+ async doPoll() {
30
54
  if (this.destroyed)
31
55
  return;
32
56
  const projects = this.projectStore.list();
@@ -268,71 +268,6 @@ async function startServer(devMode, port) {
268
268
  res.json({ valid: false });
269
269
  }
270
270
  });
271
- // Active editor detection — single AppleScript gets frontmost app + window title
272
- const editorPatterns = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
273
- let editorCache = { projectName: null };
274
- const editorScript = `
275
- tell application "System Events"
276
- set frontApp to name of first application process whose frontmost is true
277
- set winTitle to ""
278
- tell process frontApp
279
- if exists front window then
280
- set winTitle to name of front window
281
- end if
282
- end tell
283
- return frontApp & linefeed & winTitle
284
- end tell
285
- `;
286
- function pollActiveEditor() {
287
- if (process.platform !== 'darwin')
288
- return;
289
- execFile('osascript', ['-e', editorScript], { timeout: 2000 }, (err, stdout, stderr) => {
290
- if (err) {
291
- const needsAccess = stderr?.includes('not allowed assistive access') || stderr?.includes('1719');
292
- editorCache = { projectName: null, needsAccessibility: needsAccess || undefined };
293
- return;
294
- }
295
- const lines = stdout.trim().split('\n');
296
- const appName = (lines[0] || '').trim();
297
- const title = (lines[1] || '').trim();
298
- const isEditor = editorPatterns.some((pat) => appName.toLowerCase().includes(pat));
299
- if (!isEditor || !title) {
300
- editorCache = { projectName: null };
301
- return;
302
- }
303
- let projectName = null;
304
- // Try to extract a path from the title (e.g. "~/Documents/source/foo - branch")
305
- const pathMatch = title.match(/^(~?\/[^\s]+)/);
306
- if (pathMatch) {
307
- const segments = pathMatch[1].replace(/\/$/, '').split('/');
308
- projectName = segments[segments.length - 1] || null;
309
- }
310
- // Fallback: default title format "file — project — Editor" or "project — Editor"
311
- if (!projectName) {
312
- const parts = title.split(' \u2014 ');
313
- if (parts.length >= 3) {
314
- projectName = parts[parts.length - 2];
315
- }
316
- else if (parts.length === 2) {
317
- projectName = parts[0];
318
- }
319
- else {
320
- projectName = title;
321
- }
322
- }
323
- const prev = editorCache.projectName;
324
- editorCache = { projectName };
325
- if (projectName && projectName !== prev) {
326
- wsHandler.send({ type: 'editor:active', projectName });
327
- }
328
- });
329
- }
330
- // Poll every 500ms — single osascript call is fast
331
- pollActiveEditor();
332
- setInterval(pollActiveEditor, 500);
333
- app.get('/api/active-editor', (_req, res) => {
334
- res.json(editorCache);
335
- });
336
271
  // Resolve a dropped file's full path using OS file index (Spotlight on macOS)
337
272
  app.post('/api/resolve-path', (req, res) => {
338
273
  const { name, size, lastModified } = req.body;
@@ -415,6 +350,9 @@ async function startServer(devMode, port) {
415
350
  const server = http.createServer(app);
416
351
  // WebSocket handler
417
352
  const wsHandler = new WsHandler(server, ptyManager, projectStore, { onIdle: () => shutdown() });
353
+ app.get('/api/active-editor', (_req, res) => {
354
+ res.json(wsHandler.getEditorState());
355
+ });
418
356
  // IPC listener
419
357
  const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
420
358
  server.on('error', (err) => {
@@ -9,10 +9,31 @@ export class PortMonitor {
9
9
  pollTimer = null;
10
10
  onChange;
11
11
  destroyed = false;
12
+ polling = false;
13
+ paused = true;
12
14
  constructor(onChange) {
13
15
  this.onChange = onChange;
16
+ }
17
+ resume() {
18
+ if (this.destroyed || !this.paused)
19
+ return;
20
+ this.paused = false;
14
21
  this.pollTimer = setInterval(() => this.poll(), 5000);
15
22
  }
23
+ pause() {
24
+ this.paused = true;
25
+ if (this.pollTimer) {
26
+ clearInterval(this.pollTimer);
27
+ this.pollTimer = null;
28
+ }
29
+ }
30
+ getPortStatus() {
31
+ const result = {};
32
+ for (const [pid, ports] of this.alivePorts) {
33
+ result[pid] = [...ports];
34
+ }
35
+ return result;
36
+ }
16
37
  scanOutput(terminalId, projectId, data) {
17
38
  if (this.destroyed)
18
39
  return;
@@ -55,7 +76,7 @@ export class PortMonitor {
55
76
  found = true;
56
77
  }
57
78
  }
58
- if (found) {
79
+ if (found && !this.paused) {
59
80
  this.poll();
60
81
  }
61
82
  }
@@ -88,6 +109,17 @@ export class PortMonitor {
88
109
  this.alivePorts.clear();
89
110
  }
90
111
  async poll() {
112
+ if (this.destroyed || this.polling)
113
+ return;
114
+ this.polling = true;
115
+ try {
116
+ await this.doPoll();
117
+ }
118
+ finally {
119
+ this.polling = false;
120
+ }
121
+ }
122
+ async doPoll() {
91
123
  if (this.destroyed)
92
124
  return;
93
125
  // Build projectId → Set<port> from all terminals
@@ -128,7 +160,7 @@ export class PortMonitor {
128
160
  resolve();
129
161
  }
130
162
  });
131
- sock.setTimeout(2000, () => {
163
+ sock.setTimeout(500, () => {
132
164
  sock.destroy();
133
165
  if (fallback) {
134
166
  tryConnect(fallback);