@jxtools/promptline 1.3.16 → 1.3.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxtools/promptline",
3
- "version": "1.3.16",
3
+ "version": "1.3.19",
4
4
  "type": "module",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -32,13 +32,18 @@ else
32
32
  exit 0
33
33
  fi
34
34
 
35
- QUEUE_DIR="$(dirname "$QUEUE_FILE")"
36
- export QUEUE_FILE QUEUE_DIR SESSION_ID QUEUES_BASE
35
+ export QUEUE_FILE SESSION_ID QUEUES_BASE
37
36
 
38
37
  python3 << 'PYEOF'
39
- import json, os, glob, tempfile
38
+ import glob
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import tempfile
40
43
  from datetime import datetime, timezone
41
44
 
45
+ LEGACY_ORPHAN_TTL_SECONDS = 24 * 60 * 60
46
+
42
47
  def atomic_write(path, obj):
43
48
  dir_name = os.path.dirname(path)
44
49
  fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
@@ -51,6 +56,81 @@ def atomic_write(path, obj):
51
56
  except OSError: pass
52
57
  raise
53
58
 
59
+ def parse_iso_datetime(value):
60
+ if not isinstance(value, str) or not value:
61
+ return None
62
+ try:
63
+ dt = datetime.fromisoformat(value)
64
+ except ValueError:
65
+ if value.endswith("Z"):
66
+ try:
67
+ dt = datetime.fromisoformat(value[:-1] + "+00:00")
68
+ except ValueError:
69
+ return None
70
+ else:
71
+ return None
72
+ if dt.tzinfo is None:
73
+ dt = dt.replace(tzinfo=timezone.utc)
74
+ return dt.astimezone(timezone.utc)
75
+
76
+ def is_process_alive(pid):
77
+ try:
78
+ os.kill(pid, 0)
79
+ except ProcessLookupError:
80
+ return False
81
+ except PermissionError:
82
+ return True
83
+ except OSError:
84
+ return False
85
+ return True
86
+
87
+ def read_process_started_at(pid):
88
+ try:
89
+ output = subprocess.check_output(
90
+ ["ps", "-p", str(pid), "-o", "lstart="],
91
+ stderr=subprocess.DEVNULL,
92
+ text=True,
93
+ ).strip()
94
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
95
+ return None
96
+ return output or None
97
+
98
+ def has_live_owner_process(data):
99
+ owner_pid = data.get("ownerPid")
100
+ if type(owner_pid) is not int or owner_pid <= 0:
101
+ return None
102
+ if not is_process_alive(owner_pid):
103
+ return False
104
+
105
+ expected_started_at = data.get("ownerStartedAt")
106
+ if not isinstance(expected_started_at, str) or not expected_started_at.strip():
107
+ return True
108
+
109
+ actual_started_at = read_process_started_at(owner_pid)
110
+ if actual_started_at is None:
111
+ return False
112
+ return actual_started_at == expected_started_at.strip()
113
+
114
+ def is_legacy_session_stale(data, now_dt):
115
+ activity_dt = parse_iso_datetime(data.get("lastActivity"))
116
+ if activity_dt is None:
117
+ activity_dt = parse_iso_datetime(data.get("startedAt"))
118
+ if activity_dt is None:
119
+ return False
120
+ return (now_dt - activity_dt).total_seconds() >= LEGACY_ORPHAN_TTL_SECONDS
121
+
122
+ def should_close_as_orphan(data, now_dt):
123
+ if data.get("closedAt") is not None:
124
+ return False
125
+
126
+ owner_status = has_live_owner_process(data)
127
+ if owner_status is True:
128
+ return False
129
+ if owner_status is False:
130
+ return True
131
+
132
+ return is_legacy_session_stale(data, now_dt)
133
+
54
134
  def close_session(path, now, data=None):
55
135
  if data is None:
56
136
  with open(path, "r") as f:
@@ -61,12 +141,14 @@ def close_session(path, now, data=None):
61
141
  p["completedAt"] = now
62
142
  data["closedAt"] = now
63
143
  data["lastActivity"] = now
144
+ data["ownerPid"] = None
145
+ data["ownerStartedAt"] = None
64
146
  atomic_write(path, data)
65
147
 
66
148
  queue_file = os.environ["QUEUE_FILE"]
67
- queue_dir = os.environ["QUEUE_DIR"]
68
149
  session_id = os.environ["SESSION_ID"]
69
- now = datetime.now(timezone.utc).isoformat()
150
+ now_dt = datetime.now(timezone.utc)
151
+ now = now_dt.isoformat()
70
152
 
71
153
  try:
72
154
  close_session(queue_file, now)
@@ -83,7 +165,7 @@ for project_dir in glob.glob(os.path.join(queues_base, "*")):
83
165
  try:
84
166
  with open(path, "r") as f:
85
167
  data = json.load(f)
86
- if data.get("closedAt") is not None:
168
+ if not should_close_as_orphan(data, now_dt):
87
169
  continue
88
170
  close_session(path, now, data)
89
171
  except (json.JSONDecodeError, IOError, OSError):
@@ -24,6 +24,12 @@ if [ -z "$CWD" ] || [ -z "$SESSION_ID" ]; then
24
24
  exit 0
25
25
  fi
26
26
 
27
+ OWNER_PID="${PPID:-}"
28
+ OWNER_STARTED_AT=""
29
+ if [ -n "$OWNER_PID" ]; then
30
+ OWNER_STARTED_AT=$(ps -p "$OWNER_PID" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//' || true)
31
+ fi
32
+
27
33
  # Search for existing session across all projects
28
34
  QUEUES_BASE="$HOME/.promptline/queues"
29
35
  EXISTING=$(find "$QUEUES_BASE" -maxdepth 2 -name "$SESSION_ID.json" -print -quit 2>/dev/null || true)
@@ -39,7 +45,7 @@ else
39
45
  mkdir -p "$QUEUE_DIR"
40
46
  fi
41
47
 
42
- export QUEUE_FILE SESSION_ID CWD PROJECT TRANSCRIPT_PATH
48
+ export QUEUE_FILE SESSION_ID CWD PROJECT TRANSCRIPT_PATH OWNER_PID OWNER_STARTED_AT
43
49
 
44
50
  python3 << 'PYEOF'
45
51
  import json, os, tempfile
@@ -89,15 +95,31 @@ session_id = os.environ["SESSION_ID"]
89
95
  cwd = os.environ["CWD"]
90
96
  project = os.environ["PROJECT"]
91
97
  transcript_path = os.environ.get("TRANSCRIPT_PATH", "")
98
+ owner_pid_raw = os.environ.get("OWNER_PID", "").strip()
99
+ owner_started_at = os.environ.get("OWNER_STARTED_AT", "").strip() or None
92
100
  now = datetime.now(timezone.utc).isoformat()
93
101
 
102
+ try:
103
+ owner_pid = int(owner_pid_raw) if owner_pid_raw else None
104
+ except ValueError:
105
+ owner_pid = None
106
+
94
107
  if os.path.isfile(queue_file):
95
108
  try:
96
109
  with open(queue_file, "r") as f:
97
110
  data = json.load(f)
98
111
  data["lastActivity"] = now
112
+ data["closedAt"] = None
99
113
  if not data.get("sessionName"):
100
114
  data["sessionName"] = extract_session_name(transcript_path)
115
+ if owner_pid is not None and owner_pid > 0:
116
+ data["ownerPid"] = owner_pid
117
+ elif "ownerPid" not in data:
118
+ data["ownerPid"] = None
119
+ if owner_started_at is not None:
120
+ data["ownerStartedAt"] = owner_started_at
121
+ elif "ownerStartedAt" not in data:
122
+ data["ownerStartedAt"] = None
101
123
  atomic_write(queue_file, data)
102
124
  except (json.JSONDecodeError, IOError):
103
125
  pass
@@ -114,6 +136,8 @@ else:
114
136
  "currentPromptId": None,
115
137
  "completedAt": None,
116
138
  "closedAt": None,
139
+ "ownerPid": owner_pid if owner_pid is not None and owner_pid > 0 else None,
140
+ "ownerStartedAt": owner_started_at,
117
141
  }
118
142
  atomic_write(queue_file, data)
119
143
 
@@ -82,6 +82,9 @@ function hasPendingWork(session: SessionQueue): boolean {
82
82
  }
83
83
 
84
84
  export function withComputedStatus(session: SessionQueue): SessionQueue & { status: SessionStatus } {
85
+ if (session.closedAt != null) {
86
+ return { ...session, status: 'idle' };
87
+ }
85
88
  const hasRunningPrompt = session.prompts.some(p => p.status === 'running');
86
89
  const isStale = msSinceLastActivity(session) > SESSION_ACTIVE_TIMEOUT_MS;
87
90
  const status: SessionStatus = (hasRunningPrompt || !isStale) ? 'active' : 'idle';
@@ -89,8 +92,8 @@ export function withComputedStatus(session: SessionQueue): SessionQueue & { stat
89
92
  }
90
93
 
91
94
  export function isSessionVisible(session: SessionQueue, now: number = Date.now()): boolean {
92
- if (hasPendingWork(session)) return true;
93
95
  if (session.closedAt != null) return false;
96
+ if (hasPendingWork(session)) return true;
94
97
  const msSinceStart = now - new Date(session.startedAt).getTime();
95
98
  return msSinceStart <= SESSION_ABANDONED_TIMEOUT_MS;
96
99
  }
@@ -21,6 +21,8 @@ export interface SessionQueue {
21
21
  currentPromptId: string | null;
22
22
  completedAt: string | null;
23
23
  closedAt: string | null;
24
+ ownerPid?: number | null;
25
+ ownerStartedAt?: string | null;
24
26
  }
25
27
 
26
28
  export type SessionWithStatus = SessionQueue & { status: SessionStatus };