@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
|
@@ -32,13 +32,18 @@ else
|
|
|
32
32
|
exit 0
|
|
33
33
|
fi
|
|
34
34
|
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/types/queue.ts
CHANGED
|
@@ -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 };
|