@jxtools/promptline 1.3.9 → 1.3.10

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.9",
3
+ "version": "1.3.10",
4
4
  "type": "module",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -52,9 +52,40 @@ RESULT=$(python3 << 'PYEOF'
52
52
  import json
53
53
  import sys
54
54
  import os
55
+ import time
55
56
  import tempfile
56
57
  from datetime import datetime, timezone
57
58
 
59
+
60
+ def acquire_lock(lock_path, timeout=3):
61
+ deadline = time.time() + timeout
62
+ while True:
63
+ try:
64
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
65
+ os.close(fd)
66
+ return
67
+ except FileExistsError:
68
+ try:
69
+ if time.time() - os.path.getmtime(lock_path) > 10:
70
+ os.unlink(lock_path)
71
+ continue
72
+ except OSError:
73
+ continue
74
+ if time.time() >= deadline:
75
+ try:
76
+ os.unlink(lock_path)
77
+ except OSError:
78
+ pass
79
+ return
80
+ time.sleep(0.01)
81
+
82
+
83
+ def release_lock(lock_path):
84
+ try:
85
+ os.unlink(lock_path)
86
+ except OSError:
87
+ pass
88
+
58
89
  def atomic_write(path, obj):
59
90
  """Write JSON atomically: temp file + rename to prevent corruption."""
60
91
  dir_name = os.path.dirname(path)
@@ -105,82 +136,87 @@ if not queue_file:
105
136
  print("STOP")
106
137
  sys.exit(0)
107
138
 
108
- if not os.path.isfile(queue_file):
109
- cwd = os.environ.get("CWD", "")
110
- project = os.environ.get("PROJECT", "")
111
- now = datetime.now(timezone.utc).isoformat()
112
- data = {
113
- "sessionId": session_id,
114
- "project": project,
115
- "directory": cwd,
116
- "sessionName": extract_session_name(transcript_path),
117
- "prompts": [],
118
- "startedAt": now,
119
- "lastActivity": now,
120
- "currentPromptId": None,
121
- "completedAt": None,
122
- "closedAt": None,
123
- }
124
- atomic_write(queue_file, data)
125
- print("STOP")
126
- sys.exit(0)
127
-
139
+ lock_path = queue_file + ".lock"
140
+ acquire_lock(lock_path)
128
141
  try:
129
- with open(queue_file, "r") as f:
130
- data = json.load(f)
131
- except (json.JSONDecodeError, IOError):
132
- print("0")
133
- sys.exit(0)
134
-
135
- prompts = data.get("prompts", [])
136
- now = datetime.now(timezone.utc).isoformat()
137
-
138
- # Step 1: Mark any currently "running" prompts as "completed"
139
- for p in prompts:
140
- if p.get("status") == "running":
141
- p["status"] = "completed"
142
- p["completedAt"] = now
142
+ if not os.path.isfile(queue_file):
143
+ cwd = os.environ.get("CWD", "")
144
+ project = os.environ.get("PROJECT", "")
145
+ now = datetime.now(timezone.utc).isoformat()
146
+ data = {
147
+ "sessionId": session_id,
148
+ "project": project,
149
+ "directory": cwd,
150
+ "sessionName": extract_session_name(transcript_path),
151
+ "prompts": [],
152
+ "startedAt": now,
153
+ "lastActivity": now,
154
+ "currentPromptId": None,
155
+ "completedAt": None,
156
+ "closedAt": None,
157
+ }
158
+ atomic_write(queue_file, data)
159
+ print("STOP")
160
+ sys.exit(0)
143
161
 
144
- # Step 1b: Track completedAt when all prompts are done
145
- all_done = all(p.get("status") == "completed" for p in prompts) and len(prompts) > 0
146
- if all_done and not data.get("completedAt"):
147
- data["completedAt"] = now
148
-
149
- # Step 1c: Update sessionName if still null
150
- if not data.get("sessionName"):
151
- data["sessionName"] = extract_session_name(transcript_path)
152
-
153
- # Step 2: Find the first "pending" prompt
154
- next_prompt = None
155
- for p in prompts:
156
- if p.get("status") == "pending":
157
- next_prompt = p
158
- break
162
+ try:
163
+ with open(queue_file, "r") as f:
164
+ data = json.load(f)
165
+ except (json.JSONDecodeError, IOError):
166
+ print("0")
167
+ sys.exit(0)
159
168
 
160
- # Step 3: Update session tracking
161
- data["lastActivity"] = now
169
+ prompts = data.get("prompts", [])
170
+ now = datetime.now(timezone.utc).isoformat()
162
171
 
163
- if next_prompt is None:
172
+ # Step 1: Mark any currently "running" prompts as "completed"
173
+ for p in prompts:
174
+ if p.get("status") == "running":
175
+ p["status"] = "completed"
176
+ p["completedAt"] = now
177
+
178
+ # Step 1b: Track completedAt when all prompts are done
179
+ all_done = all(p.get("status") == "completed" for p in prompts) and len(prompts) > 0
180
+ if all_done and not data.get("completedAt"):
181
+ data["completedAt"] = now
182
+
183
+ # Step 1c: Update sessionName if still null
184
+ if not data.get("sessionName"):
185
+ data["sessionName"] = extract_session_name(transcript_path)
186
+
187
+ # Step 2: Find the first "pending" prompt
188
+ next_prompt = None
189
+ for p in prompts:
190
+ if p.get("status") == "pending":
191
+ next_prompt = p
192
+ break
193
+
194
+ # Step 3: Update session tracking
195
+ data["lastActivity"] = now
196
+
197
+ if next_prompt is None:
198
+ data["prompts"] = prompts
199
+ data["currentPromptId"] = None
200
+ atomic_write(queue_file, data)
201
+ print("STOP")
202
+ sys.exit(0)
203
+
204
+ # We have a pending prompt -> mark it as running
205
+ next_prompt["status"] = "running"
206
+ data["currentPromptId"] = next_prompt["id"]
164
207
  data["prompts"] = prompts
165
- data["currentPromptId"] = None
166
- atomic_write(queue_file, data)
167
- print("STOP")
168
- sys.exit(0)
169
-
170
- # We have a pending prompt -> mark it as running
171
- next_prompt["status"] = "running"
172
- data["currentPromptId"] = next_prompt["id"]
173
- data["prompts"] = prompts
174
208
 
175
- atomic_write(queue_file, data)
209
+ atomic_write(queue_file, data)
176
210
 
177
- # Count remaining pending prompts (excluding the one we just took)
178
- remaining = sum(1 for p in prompts if p.get("status") == "pending")
211
+ # Count remaining pending prompts (excluding the one we just took)
212
+ remaining = sum(1 for p in prompts if p.get("status") == "pending")
179
213
 
180
- reason = f"PromptLine ({remaining} queued)\n\n{next_prompt['text']}"
181
- decision = {"decision": "block", "reason": reason}
182
- print("CONTINUE")
183
- print(json.dumps(decision))
214
+ reason = f"PromptLine ({remaining} queued)\n\n{next_prompt['text']}"
215
+ decision = {"decision": "block", "reason": reason}
216
+ print("CONTINUE")
217
+ print(json.dumps(decision))
218
+ finally:
219
+ release_lock(lock_path)
184
220
  PYEOF
185
221
  )
186
222
 
@@ -1,9 +1,48 @@
1
- import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, rmSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, rmSync, openSync, closeSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { SessionQueue, Prompt, PromptStatus, SessionStatus, QueueStatus, ProjectView, SessionWithStatus } from '../types/queue.ts';
4
4
 
5
5
  export const SESSION_ACTIVE_TIMEOUT_MS = 60_000;
6
6
  export const SESSION_ABANDONED_TIMEOUT_MS = 24 * 60 * 60_000; // 24h safety net
7
+ const LOCK_STALE_MS = 10_000;
8
+
9
+ function acquireLockSync(lockPath: string): void {
10
+ for (let i = 0; i < 100; i++) {
11
+ try {
12
+ const fd = openSync(lockPath, 'wx');
13
+ closeSync(fd);
14
+ return;
15
+ } catch (err: unknown) {
16
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
17
+ try {
18
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
19
+ unlinkSync(lockPath);
20
+ continue;
21
+ }
22
+ } catch { continue; }
23
+ const end = Date.now() + 10;
24
+ while (Date.now() < end) { /* spin */ }
25
+ }
26
+ }
27
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
28
+ }
29
+
30
+ function releaseLockSync(lockPath: string): void {
31
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
32
+ }
33
+
34
+ export function withSessionLock<T>(
35
+ queuesDir: string, project: string, sessionId: string,
36
+ fn: () => T,
37
+ ): T {
38
+ const lockPath = sessionPath(queuesDir, project, sessionId) + '.lock';
39
+ acquireLockSync(lockPath);
40
+ try {
41
+ return fn();
42
+ } finally {
43
+ releaseLockSync(lockPath);
44
+ }
45
+ }
7
46
 
8
47
  export function ensureProjectDir(queuesDir: string, project: string): void {
9
48
  mkdirSync(join(queuesDir, project), { recursive: true });
@@ -19,6 +19,7 @@ import {
19
19
  deletePrompt,
20
20
  clearPrompts,
21
21
  reorderPrompts,
22
+ withSessionLock,
22
23
  } from './src/backend/queue-store.ts';
23
24
 
24
25
  const QUEUES_DIR = join(homedir(), '.promptline', 'queues');
@@ -232,8 +233,6 @@ async function handleApi(
232
233
  ) {
233
234
  const project = segments[1];
234
235
  const sessionId = segments[3];
235
- const session = readSession(QUEUES_DIR, project, sessionId);
236
- if (!session) return jsonError(res, 404, 'Session not found');
237
236
 
238
237
  const body = await parseBody(req);
239
238
  const order = body.order as string[] | undefined;
@@ -241,11 +240,16 @@ async function handleApi(
241
240
  return jsonError(res, 400, 'Field "order" (string[]) is required');
242
241
  }
243
242
 
244
- const error = reorderPrompts(session, order);
245
- if (error) return jsonError(res, 400, error);
243
+ return withSessionLock(QUEUES_DIR, project, sessionId, () => {
244
+ const session = readSession(QUEUES_DIR, project, sessionId);
245
+ if (!session) return jsonError(res, 404, 'Session not found');
246
+
247
+ const error = reorderPrompts(session, order);
248
+ if (error) return jsonError(res, 400, error);
246
249
 
247
- writeSession(QUEUES_DIR, project, session);
248
- return json(res, 200, session);
250
+ writeSession(QUEUES_DIR, project, session);
251
+ return json(res, 200, session);
252
+ });
249
253
  }
250
254
 
251
255
  // /api/projects/:project/sessions/:sessionId/prompts/:promptId
@@ -256,8 +260,6 @@ async function handleApi(
256
260
  const project = segments[1];
257
261
  const sessionId = segments[3];
258
262
  const promptId = segments[5];
259
- const session = readSession(QUEUES_DIR, project, sessionId);
260
- if (!session) return jsonError(res, 404, 'Session not found');
261
263
 
262
264
  if (method === 'PATCH') {
263
265
  const body = await parseBody(req);
@@ -274,19 +276,29 @@ async function handleApi(
274
276
  updates.status = body.status as PromptStatus;
275
277
  }
276
278
 
277
- const updated = updatePrompt(session, promptId, updates);
278
- if (!updated) return jsonError(res, 404, `Prompt "${promptId}" not found`);
279
+ return withSessionLock(QUEUES_DIR, project, sessionId, () => {
280
+ const session = readSession(QUEUES_DIR, project, sessionId);
281
+ if (!session) return jsonError(res, 404, 'Session not found');
279
282
 
280
- writeSession(QUEUES_DIR, project, session);
281
- return json(res, 200, updated);
283
+ const updated = updatePrompt(session, promptId, updates);
284
+ if (!updated) return jsonError(res, 404, `Prompt "${promptId}" not found`);
285
+
286
+ writeSession(QUEUES_DIR, project, session);
287
+ return json(res, 200, updated);
288
+ });
282
289
  }
283
290
 
284
291
  if (method === 'DELETE') {
285
- const removed = deletePrompt(session, promptId);
286
- if (!removed) return jsonError(res, 404, `Prompt "${promptId}" not found`);
292
+ return withSessionLock(QUEUES_DIR, project, sessionId, () => {
293
+ const session = readSession(QUEUES_DIR, project, sessionId);
294
+ if (!session) return jsonError(res, 404, 'Session not found');
287
295
 
288
- writeSession(QUEUES_DIR, project, session);
289
- return json(res, 200, removed);
296
+ const removed = deletePrompt(session, promptId);
297
+ if (!removed) return jsonError(res, 404, `Prompt "${promptId}" not found`);
298
+
299
+ writeSession(QUEUES_DIR, project, session);
300
+ return json(res, 200, removed);
301
+ });
290
302
  }
291
303
 
292
304
  return jsonError(res, 405, `Method ${method} not allowed`);
@@ -299,12 +311,15 @@ async function handleApi(
299
311
  ) {
300
312
  const project = segments[1];
301
313
  const sessionId = segments[3];
302
- const session = readSession(QUEUES_DIR, project, sessionId);
303
- if (!session) return jsonError(res, 404, 'Session not found');
304
314
 
305
- const removed = clearPrompts(session);
306
- writeSession(QUEUES_DIR, project, session);
307
- return json(res, 200, { cleared: removed.length });
315
+ return withSessionLock(QUEUES_DIR, project, sessionId, () => {
316
+ const session = readSession(QUEUES_DIR, project, sessionId);
317
+ if (!session) return jsonError(res, 404, 'Session not found');
318
+
319
+ const removed = clearPrompts(session);
320
+ writeSession(QUEUES_DIR, project, session);
321
+ return json(res, 200, { cleared: removed.length });
322
+ });
308
323
  }
309
324
 
310
325
  // /api/projects/:project/sessions/:sessionId/prompts
@@ -314,17 +329,20 @@ async function handleApi(
314
329
  ) {
315
330
  const project = segments[1];
316
331
  const sessionId = segments[3];
317
- const session = readSession(QUEUES_DIR, project, sessionId);
318
- if (!session) return jsonError(res, 404, 'Session not found');
319
332
 
320
333
  const body = await parseBody(req);
321
334
  if (!body.text || typeof body.text !== 'string') {
322
335
  return jsonError(res, 400, 'Field "text" (string) is required');
323
336
  }
324
337
 
325
- const prompt = addPrompt(session, uuidv4(), body.text as string);
326
- writeSession(QUEUES_DIR, project, session);
327
- return json(res, 201, prompt);
338
+ return withSessionLock(QUEUES_DIR, project, sessionId, () => {
339
+ const session = readSession(QUEUES_DIR, project, sessionId);
340
+ if (!session) return jsonError(res, 404, 'Session not found');
341
+
342
+ const prompt = addPrompt(session, uuidv4(), body.text as string);
343
+ writeSession(QUEUES_DIR, project, session);
344
+ return json(res, 201, prompt);
345
+ });
328
346
  }
329
347
 
330
348
  return jsonError(res, 404, 'Not found');