paneful 0.8.8 → 0.8.9

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,97 +1,58 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- // If the latest JSONL mtime is < 5s ago, Claude is actively working.
5
- // Otherwise Claude is open but idle (waiting for user input).
6
- const ACTIVE_THRESHOLD = 5_000;
1
+ // Terminal had output within this window → Claude is actively working
2
+ const ACTIVE_THRESHOLD = 3_000;
7
3
  export class ClaudeMonitor {
8
- claudeDir;
9
4
  ptyManager;
10
- projectStore;
11
5
  onChange;
6
+ lastOutput = new Map(); // terminalId → timestamp
12
7
  prevStatuses = {};
13
- cachedLatestFile = new Map(); // cwd → latest .jsonl path
14
8
  pollTimer = null;
15
9
  destroyed = false;
16
- constructor(ptyManager, projectStore, onChange) {
10
+ constructor(ptyManager, onChange) {
17
11
  this.ptyManager = ptyManager;
18
- this.projectStore = projectStore;
19
12
  this.onChange = onChange;
20
- this.claudeDir = path.join(os.homedir(), '.claude', 'projects');
21
13
  }
22
14
  start() {
23
15
  this.pollTimer = setInterval(() => this.poll(), 3000);
24
16
  }
17
+ /** Call from the PTY output path to record activity. */
18
+ recordOutput(terminalId) {
19
+ this.lastOutput.set(terminalId, Date.now());
20
+ }
21
+ /** Clean up when a terminal is removed. */
22
+ removeTerminal(terminalId) {
23
+ this.lastOutput.delete(terminalId);
24
+ }
25
25
  destroy() {
26
26
  this.destroyed = true;
27
27
  if (this.pollTimer) {
28
28
  clearInterval(this.pollTimer);
29
29
  this.pollTimer = null;
30
30
  }
31
+ this.lastOutput.clear();
31
32
  }
32
33
  poll() {
33
34
  if (this.destroyed)
34
35
  return;
35
- // Step 1: Ask pty-manager which projects have `claude` as foreground process
36
+ const now = Date.now();
37
+ // Which projects have claude running, and what's the latest output time per project?
36
38
  const claudeProjects = this.ptyManager.getClaudeProjects();
37
- // Step 2: For each, determine active vs idle by checking JSONL mtime
38
39
  const statuses = {};
39
- for (const projectId of claudeProjects) {
40
- const project = this.projectStore.get(projectId);
41
- if (!project)
42
- continue;
43
- const status = this.checkMtime(project.cwd) ? 'active' : 'idle';
44
- statuses[projectId] = status;
40
+ for (const [projectId, terminalIds] of claudeProjects) {
41
+ let latestOutput = 0;
42
+ for (const tid of terminalIds) {
43
+ const ts = this.lastOutput.get(tid) ?? 0;
44
+ if (ts > latestOutput)
45
+ latestOutput = ts;
46
+ }
47
+ statuses[projectId] = (latestOutput > 0 && (now - latestOutput) < ACTIVE_THRESHOLD)
48
+ ? 'active'
49
+ : 'idle';
45
50
  }
46
- // Step 3: Only notify if something changed
47
51
  if (!this.statusesEqual(statuses)) {
48
52
  this.prevStatuses = statuses;
49
53
  this.onChange(statuses);
50
54
  }
51
55
  }
52
- /** Returns true if the project's latest JSONL was modified within ACTIVE_THRESHOLD. */
53
- checkMtime(cwd) {
54
- // Fast path: stat only the cached latest file
55
- const cached = this.cachedLatestFile.get(cwd);
56
- if (cached) {
57
- try {
58
- const stat = fs.statSync(cached);
59
- if ((Date.now() - stat.mtimeMs) < ACTIVE_THRESHOLD)
60
- return true;
61
- }
62
- catch {
63
- // File gone — fall through to full scan
64
- this.cachedLatestFile.delete(cwd);
65
- }
66
- }
67
- // Full scan: find the latest .jsonl (runs once on first check, then only
68
- // when the cached file is stale — i.e. Claude started a new session)
69
- const folder = path.join(this.claudeDir, cwd.replace(/\//g, '-'));
70
- try {
71
- const files = fs.readdirSync(folder).filter((f) => f.endsWith('.jsonl'));
72
- let maxMtime = 0;
73
- let maxFile = '';
74
- for (const file of files) {
75
- try {
76
- const filePath = path.join(folder, file);
77
- const stat = fs.statSync(filePath);
78
- if (stat.mtimeMs > maxMtime) {
79
- maxMtime = stat.mtimeMs;
80
- maxFile = filePath;
81
- }
82
- }
83
- catch {
84
- // skip
85
- }
86
- }
87
- if (maxFile)
88
- this.cachedLatestFile.set(cwd, maxFile);
89
- return maxMtime > 0 && (Date.now() - maxMtime) < ACTIVE_THRESHOLD;
90
- }
91
- catch {
92
- return false;
93
- }
94
- }
95
56
  statusesEqual(next) {
96
57
  const prevKeys = Object.keys(this.prevStatuses);
97
58
  const nextKeys = Object.keys(next);
@@ -72,14 +72,17 @@ export class PtyManager {
72
72
  terminalExists(terminalId) {
73
73
  return this.sessions.has(terminalId);
74
74
  }
75
- /** Returns projectIds that have a terminal with `claude` as the foreground process. */
75
+ /** Returns projectId terminalIds[] for terminals with `claude` as foreground process. */
76
76
  getClaudeProjects() {
77
- const result = new Set();
78
- for (const managed of this.sessions.values()) {
77
+ const result = new Map();
78
+ for (const [terminalId, managed] of this.sessions) {
79
79
  try {
80
- const proc = managed.process.process;
81
- if (proc === 'claude') {
82
- result.add(managed.projectId);
80
+ if (managed.process.process === 'claude') {
81
+ const list = result.get(managed.projectId);
82
+ if (list)
83
+ list.push(terminalId);
84
+ else
85
+ result.set(managed.projectId, [terminalId]);
83
86
  }
84
87
  }
85
88
  catch {
@@ -18,7 +18,7 @@ export class WsHandler {
18
18
  this.portMonitor = new PortMonitor((ports) => {
19
19
  this.send({ type: 'port:status', ports });
20
20
  });
21
- this.claudeMonitor = new ClaudeMonitor(ptyManager, projectStore, (statuses) => {
21
+ this.claudeMonitor = new ClaudeMonitor(ptyManager, (statuses) => {
22
22
  this.send({ type: 'claude:status', statuses });
23
23
  });
24
24
  this.claudeMonitor.start();
@@ -92,6 +92,7 @@ export class WsHandler {
92
92
  break;
93
93
  case 'pty:kill': {
94
94
  this.portMonitor.removeTerminal(msg.terminalId);
95
+ this.claudeMonitor.removeTerminal(msg.terminalId);
95
96
  const projectId = this.ptyManager.kill(msg.terminalId);
96
97
  if (projectId) {
97
98
  this.projectStore.removeTerminal(projectId, msg.terminalId);
@@ -132,8 +133,10 @@ export class WsHandler {
132
133
  this.ptyManager.spawn(terminalId, projectId, cwd, (tid, data) => {
133
134
  this.send({ type: 'pty:output', terminalId: tid, data });
134
135
  this.portMonitor.scanOutput(tid, projectId, data);
136
+ this.claudeMonitor.recordOutput(tid);
135
137
  }, (tid, exitCode) => {
136
138
  this.portMonitor.removeTerminal(tid);
139
+ this.claudeMonitor.removeTerminal(tid);
137
140
  this.send({ type: 'pty:exit', terminalId: tid, exitCode });
138
141
  });
139
142
  this.projectStore.addTerminal(projectId, terminalId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paneful",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Browser-based terminal multiplexer with tmux-style pane management",
5
5
  "type": "module",
6
6
  "bin": {