@jxtools/promptline 1.0.0

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/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # PromptLine
2
+
3
+ A prompt queue system for Claude Code.
4
+
5
+ Ever been watching Claude Code work and thought of three more things you need it to do next? PromptLine lets you line up those prompts so Claude picks them up automatically — one after another, without missing a single detail.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @jxtools/promptline
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ promptline
17
+ ```
18
+
19
+ On first run, PromptLine asks to install its hooks into Claude Code. After that, it opens the dashboard.
20
+
21
+ ## How it works
22
+
23
+ Start your Claude Code sessions as usual. Then open PromptLine in a separate terminal — it detects all running sessions automatically.
24
+
25
+ While Claude is working, add prompts to the queue from the dashboard. When Claude finishes its current task, it picks up the next queued prompt and keeps going. Each session has its own independent queue, so multiple sessions in the same project don't interfere with each other.
26
+
27
+ 1. **Work with Claude Code** — Start your sessions normally
28
+ 2. **Open PromptLine** — Run `promptline` in a separate terminal
29
+ 3. **Queue your prompts** — Add them while Claude works, they execute in order
30
+
31
+ ## Update
32
+
33
+ ```bash
34
+ promptline update
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - Node.js 18+
40
+ - Python 3
41
+ - Claude Code installed
42
+
43
+ ## License
44
+
45
+ ISC
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync } from 'fs'
4
+ import { resolve, dirname, join } from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { spawn, execSync } from 'child_process'
7
+ import { createInterface } from 'readline'
8
+ import { homedir } from 'os'
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url))
11
+ const pkgDir = resolve(__dirname, '..')
12
+ const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
13
+
14
+ // --version
15
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
16
+ console.log(`promptline v${pkg.version}`)
17
+ process.exit(0)
18
+ }
19
+
20
+ // update
21
+ if (process.argv[2] === 'update') {
22
+ const current = pkg.version
23
+ console.log(`\x1b[36m⟳\x1b[0m Current version: v${current}`)
24
+ console.log(` Checking for updates...`)
25
+
26
+ try {
27
+ const latest = execSync('npm view @jxtools/promptline version', { encoding: 'utf-8' }).trim()
28
+
29
+ if (latest === current) {
30
+ console.log(`\x1b[32m✓\x1b[0m Already on the latest version (v${current})`)
31
+ process.exit(0)
32
+ }
33
+
34
+ console.log(`\x1b[33m↑\x1b[0m New version available: v${latest}`)
35
+ console.log(` Updating...`)
36
+
37
+ execSync('npm install -g @jxtools/promptline@latest', { stdio: 'inherit' })
38
+ console.log(`\n\x1b[32m✓\x1b[0m Updated to v${latest}`)
39
+ } catch (err) {
40
+ console.error(`\x1b[31m✗\x1b[0m Update failed: ${err.message}`)
41
+ process.exit(1)
42
+ }
43
+
44
+ process.exit(0)
45
+ }
46
+
47
+ // Check Claude Code is installed
48
+ const claudeDir = join(homedir(), '.claude')
49
+ if (!existsSync(claudeDir)) {
50
+ console.error('\x1b[31m✗\x1b[0m Claude Code not found. Install it first.')
51
+ process.exit(1)
52
+ }
53
+
54
+ // Check hooks installation
55
+ const hooksDir = join(claudeDir, 'hooks')
56
+ const hookFiles = [
57
+ 'promptline-session-register.sh',
58
+ 'promptline-prompt-queue.sh',
59
+ 'promptline-session-end.sh',
60
+ ]
61
+
62
+ const allHooksInstalled = hookFiles.every(f => existsSync(join(hooksDir, f)))
63
+
64
+ if (!allHooksInstalled) {
65
+ const answer = await ask('Install PromptLine hooks for Claude Code? (Y/n) ')
66
+
67
+ if (answer.toLowerCase() === 'n') {
68
+ console.log(' Skipped. Run promptline again to install later.')
69
+ process.exit(0)
70
+ }
71
+
72
+ installHooks()
73
+ }
74
+
75
+ // Start Vite dev server
76
+ const viteBin = resolve(pkgDir, 'node_modules', '.bin', 'vite')
77
+ const vite = spawn(viteBin, [], {
78
+ cwd: pkgDir,
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ env: { ...process.env, FORCE_COLOR: '0' },
81
+ })
82
+
83
+ let opened = false
84
+ const stripAnsi = (str) => str.replace(/\x1b\[[0-9;]*m/g, '')
85
+
86
+ vite.stdout.on('data', (data) => {
87
+ const line = stripAnsi(data.toString())
88
+ const match = line.match(/localhost:(\d+)/)
89
+
90
+ if (match && !opened) {
91
+ opened = true
92
+ const port = match[1]
93
+ const url = `http://localhost:${port}`
94
+ console.log(`\x1b[32m✓\x1b[0m PromptLine running at \x1b[36m${url}\x1b[0m`)
95
+ console.log(` Press \x1b[33mCtrl+C\x1b[0m to stop\n`)
96
+ }
97
+ })
98
+
99
+ vite.stderr.on('data', (data) => {
100
+ const line = data.toString().trim()
101
+ if (line && !line.includes('ExperimentalWarning')) {
102
+ process.stderr.write(data)
103
+ }
104
+ })
105
+
106
+ vite.on('close', (code) => process.exit(code ?? 0))
107
+
108
+ process.on('SIGINT', () => {
109
+ vite.kill('SIGINT')
110
+ console.log('\n\x1b[33m⏹\x1b[0m PromptLine stopped.')
111
+ process.exit(0)
112
+ })
113
+
114
+ // --- Helpers ---
115
+
116
+ function ask(question) {
117
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
118
+ return new Promise((resolve) => {
119
+ rl.question(question, (answer) => {
120
+ rl.close()
121
+ resolve(answer.trim() || 'y')
122
+ })
123
+ })
124
+ }
125
+
126
+ function installHooks() {
127
+ // Copy hook scripts
128
+ execSync(`mkdir -p "${hooksDir}"`)
129
+
130
+ for (const file of hookFiles) {
131
+ const src = join(pkgDir, file)
132
+ const dest = join(hooksDir, file)
133
+ copyFileSync(src, dest)
134
+ chmodSync(dest, 0o755)
135
+ }
136
+
137
+ // Merge into settings.json
138
+ const settingsPath = join(claudeDir, 'settings.json')
139
+ let settings = {}
140
+
141
+ if (existsSync(settingsPath)) {
142
+ try {
143
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
144
+ } catch {
145
+ // corrupted settings, start fresh
146
+ }
147
+ }
148
+
149
+ if (!settings.hooks) settings.hooks = {}
150
+
151
+ const hookConfig = {
152
+ SessionStart: {
153
+ file: 'promptline-session-register.sh',
154
+ },
155
+ Stop: {
156
+ file: 'promptline-prompt-queue.sh',
157
+ },
158
+ SessionEnd: {
159
+ file: 'promptline-session-end.sh',
160
+ },
161
+ }
162
+
163
+ for (const [event, config] of Object.entries(hookConfig)) {
164
+ const command = `~/.claude/hooks/${config.file}`
165
+
166
+ if (!settings.hooks[event]) settings.hooks[event] = []
167
+
168
+ const alreadyExists = settings.hooks[event].some(entry =>
169
+ entry.hooks?.some(h => h.command === command)
170
+ )
171
+
172
+ if (!alreadyExists) {
173
+ settings.hooks[event].push({
174
+ hooks: [{ type: 'command', command }],
175
+ })
176
+ }
177
+ }
178
+
179
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
180
+ console.log(`\x1b[32m✓\x1b[0m Hooks installed`)
181
+ }
package/index.html ADDED
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <title>PromptLine</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@jxtools/promptline",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "license": "ISC",
6
+ "bin": {
7
+ "promptline": "./bin/promptline.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "promptline-*.sh",
13
+ "vite-plugin-api.ts",
14
+ "vite.config.ts",
15
+ "tsconfig.json",
16
+ "tsconfig.app.json",
17
+ "tsconfig.node.json",
18
+ "index.html",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "dev": "vite",
26
+ "build": "tsc -b && vite build",
27
+ "lint": "eslint .",
28
+ "preview": "vite preview",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "@tailwindcss/vite": "^4.2.1",
34
+ "@vitejs/plugin-react": "^5.1.1",
35
+ "react": "^19.2.0",
36
+ "react-dom": "^19.2.0",
37
+ "tailwindcss": "^4.2.1",
38
+ "typescript": "~5.9.3",
39
+ "uuid": "^13.0.0",
40
+ "vite": "^7.3.1"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^9.39.1",
44
+ "@types/node": "^24.10.1",
45
+ "@types/react": "^19.2.7",
46
+ "@types/react-dom": "^19.2.3",
47
+ "@types/uuid": "^10.0.0",
48
+ "eslint": "^9.39.1",
49
+ "eslint-plugin-react-hooks": "^7.0.1",
50
+ "eslint-plugin-react-refresh": "^0.4.24",
51
+ "globals": "^16.5.0",
52
+ "typescript-eslint": "^8.48.0",
53
+ "vitest": "^4.0.18"
54
+ }
55
+ }
@@ -0,0 +1,173 @@
1
+ #!/bin/bash
2
+ # promptline-prompt-queue.sh — Stop hook for Claude Code.
3
+ # Reads from ~/.promptline/queues/{project}/{session_id}.json.
4
+ # If a pending prompt exists, outputs {"decision":"block","reason":"..."}
5
+ # so Claude continues with the next queued prompt.
6
+ # If no pending prompts remain, exits 0 (Claude stops normally).
7
+
8
+ set -euo pipefail
9
+
10
+ # --- Read Claude Code hook input from stdin ---
11
+ INPUT=$(cat)
12
+
13
+ # --- Extract session_id, cwd, and transcript_path from input JSON ---
14
+ PARSED=$(echo "$INPUT" | python3 -c "
15
+ import sys, json
16
+ data = json.load(sys.stdin)
17
+ print(data.get('session_id', ''))
18
+ print(data.get('cwd', ''))
19
+ print(data.get('transcript_path', ''))
20
+ print(data.get('stop_hook_active', False))
21
+ " 2>/dev/null) || PARSED=$'\n\n\n'
22
+
23
+ SESSION_ID=$(echo "$PARSED" | sed -n '1p')
24
+ CWD=$(echo "$PARSED" | sed -n '2p')
25
+ TRANSCRIPT_PATH=$(echo "$PARSED" | sed -n '3p')
26
+ STOP_HOOK_ACTIVE=$(echo "$PARSED" | sed -n '4p')
27
+
28
+ # If no cwd, nothing to do
29
+ if [ -z "$CWD" ]; then
30
+ exit 0
31
+ fi
32
+
33
+ # --- Derive project name and session file path ---
34
+ PROJECT=$(basename "$CWD")
35
+ QUEUE_DIR="$HOME/.promptline/queues/$PROJECT"
36
+ QUEUE_FILE="$QUEUE_DIR/$SESSION_ID.json"
37
+
38
+ export QUEUE_FILE SESSION_ID CWD PROJECT TRANSCRIPT_PATH STOP_HOOK_ACTIVE
39
+
40
+ # No session file -> nothing to do (SessionStart hook handles registration)
41
+ if [ ! -f "$QUEUE_FILE" ]; then
42
+ exit 0
43
+ fi
44
+
45
+ # --- Process queue with python3 ---
46
+ RESULT=$(python3 << 'PYEOF'
47
+ import json
48
+ import sys
49
+ import os
50
+ import tempfile
51
+ from datetime import datetime, timezone
52
+
53
+ def atomic_write(path, obj):
54
+ """Write JSON atomically: temp file + rename to prevent corruption."""
55
+ dir_name = os.path.dirname(path)
56
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
57
+ try:
58
+ with os.fdopen(fd, "w") as f:
59
+ json.dump(obj, f, indent=2)
60
+ os.replace(tmp_path, path)
61
+ except Exception:
62
+ try:
63
+ os.unlink(tmp_path)
64
+ except OSError:
65
+ pass
66
+ raise
67
+
68
+ def extract_session_name(transcript_path, max_len=50):
69
+ """Extract first user message from transcript JSONL as session name."""
70
+ if not transcript_path or not os.path.isfile(transcript_path):
71
+ return None
72
+ try:
73
+ with open(transcript_path, "r") as f:
74
+ for line in f:
75
+ try:
76
+ entry = json.loads(line.strip())
77
+ if entry.get("type") == "user":
78
+ msg = entry.get("message", {})
79
+ content = msg.get("content", "")
80
+ if isinstance(content, str) and content.strip():
81
+ text = content.strip().replace("\n", " ")
82
+ return text[:max_len] + "..." if len(text) > max_len else text
83
+ elif isinstance(content, list):
84
+ for part in content:
85
+ if isinstance(part, dict) and part.get("type") == "text":
86
+ text = part.get("text", "").strip().replace("\n", " ")
87
+ if text:
88
+ return text[:max_len] + "..." if len(text) > max_len else text
89
+ except (json.JSONDecodeError, KeyError):
90
+ continue
91
+ except (IOError, OSError):
92
+ pass
93
+ return None
94
+
95
+ queue_file = os.environ.get("QUEUE_FILE", "")
96
+ session_id = os.environ.get("SESSION_ID", "")
97
+ transcript_path = os.environ.get("TRANSCRIPT_PATH", "")
98
+
99
+ if not queue_file or not os.path.isfile(queue_file):
100
+ print("STOP")
101
+ sys.exit(0)
102
+
103
+ try:
104
+ with open(queue_file, "r") as f:
105
+ data = json.load(f)
106
+ except (json.JSONDecodeError, IOError):
107
+ print("0")
108
+ sys.exit(0)
109
+
110
+ prompts = data.get("prompts", [])
111
+ now = datetime.now(timezone.utc).isoformat()
112
+
113
+ # Step 1: Mark any currently "running" prompts as "completed"
114
+ for p in prompts:
115
+ if p.get("status") == "running":
116
+ p["status"] = "completed"
117
+ p["completedAt"] = now
118
+
119
+ # Step 1b: Track completedAt when all prompts are done
120
+ all_done = all(p.get("status") == "completed" for p in prompts) and len(prompts) > 0
121
+ if all_done and not data.get("completedAt"):
122
+ data["completedAt"] = now
123
+
124
+ # Step 1c: Update sessionName if still null
125
+ if not data.get("sessionName"):
126
+ data["sessionName"] = extract_session_name(transcript_path)
127
+
128
+ # Step 2: Find the first "pending" prompt
129
+ next_prompt = None
130
+ for p in prompts:
131
+ if p.get("status") == "pending":
132
+ next_prompt = p
133
+ break
134
+
135
+ # Step 3: Update session tracking
136
+ data["lastActivity"] = now
137
+
138
+ if next_prompt is None:
139
+ data["prompts"] = prompts
140
+ data["currentPromptId"] = None
141
+ atomic_write(queue_file, data)
142
+ print("STOP")
143
+ sys.exit(0)
144
+
145
+ # We have a pending prompt -> mark it as running
146
+ next_prompt["status"] = "running"
147
+ data["currentPromptId"] = next_prompt["id"]
148
+ data["prompts"] = prompts
149
+
150
+ atomic_write(queue_file, data)
151
+
152
+ # Count remaining pending prompts (excluding the one we just took)
153
+ remaining = sum(1 for p in prompts if p.get("status") == "pending")
154
+
155
+ reason = f"PromptLine ({remaining} queued)\n\n{next_prompt['text']}"
156
+ decision = {"decision": "block", "reason": reason}
157
+ print("CONTINUE")
158
+ print(json.dumps(decision))
159
+ PYEOF
160
+ )
161
+
162
+ # --- Handle python output ---
163
+ ACTION=$(echo "$RESULT" | head -n1)
164
+
165
+ if [ "$ACTION" = "CONTINUE" ]; then
166
+ # Output JSON decision on stdout so Claude Code continues with next prompt.
167
+ # Safe from infinite loops: the queue drains (pending -> running -> completed)
168
+ # and the hook exits 0 without blocking when no prompts remain.
169
+ echo "$RESULT" | sed '1d'
170
+ exit 0
171
+ fi
172
+
173
+ exit 0
@@ -0,0 +1,58 @@
1
+ #!/bin/bash
2
+ # promptline-session-end.sh
3
+ # SessionEnd hook: marks a session as closed when Claude Code exits.
4
+ # Updates closedAt and lastActivity in ~/.promptline/queues/{project}/{session_id}.json
5
+
6
+ set -euo pipefail
7
+
8
+ INPUT=$(cat)
9
+
10
+ PARSED=$(echo "$INPUT" | python3 -c "
11
+ import sys, json
12
+ data = json.load(sys.stdin)
13
+ print(data.get('session_id', ''))
14
+ print(data.get('cwd', ''))
15
+ " 2>/dev/null) || PARSED=$'\n'
16
+
17
+ SESSION_ID=$(echo "$PARSED" | sed -n '1p')
18
+ CWD=$(echo "$PARSED" | sed -n '2p')
19
+
20
+ if [ -z "$CWD" ] || [ -z "$SESSION_ID" ]; then
21
+ exit 0
22
+ fi
23
+
24
+ PROJECT=$(basename "$CWD")
25
+ QUEUE_FILE="$HOME/.promptline/queues/$PROJECT/$SESSION_ID.json"
26
+ export QUEUE_FILE
27
+
28
+ python3 << 'PYEOF'
29
+ import json, os, tempfile
30
+ from datetime import datetime, timezone
31
+
32
+ def atomic_write(path, obj):
33
+ dir_name = os.path.dirname(path)
34
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
35
+ try:
36
+ with os.fdopen(fd, "w") as f:
37
+ json.dump(obj, f, indent=2)
38
+ os.replace(tmp_path, path)
39
+ except Exception:
40
+ try: os.unlink(tmp_path)
41
+ except OSError: pass
42
+ raise
43
+
44
+ queue_file = os.environ["QUEUE_FILE"]
45
+ now = datetime.now(timezone.utc).isoformat()
46
+
47
+ try:
48
+ with open(queue_file, "r") as f:
49
+ data = json.load(f)
50
+ data["closedAt"] = now
51
+ data["lastActivity"] = now
52
+ atomic_write(queue_file, data)
53
+ except (json.JSONDecodeError, IOError, OSError):
54
+ pass
55
+
56
+ PYEOF
57
+
58
+ exit 0
@@ -0,0 +1,113 @@
1
+ #!/bin/bash
2
+ # promptline-session-register.sh
3
+ # SessionStart hook: auto-creates per-session queue file when Claude Code opens.
4
+ # Stores at ~/.promptline/queues/{project}/{session_id}.json
5
+ # Extracts session name from the transcript's first user message.
6
+
7
+ set -euo pipefail
8
+
9
+ INPUT=$(cat)
10
+
11
+ PARSED=$(echo "$INPUT" | python3 -c "
12
+ import sys, json
13
+ data = json.load(sys.stdin)
14
+ print(data.get('session_id', ''))
15
+ print(data.get('cwd', ''))
16
+ print(data.get('transcript_path', ''))
17
+ " 2>/dev/null) || PARSED=$'\n\n'
18
+
19
+ SESSION_ID=$(echo "$PARSED" | sed -n '1p')
20
+ CWD=$(echo "$PARSED" | sed -n '2p')
21
+ TRANSCRIPT_PATH=$(echo "$PARSED" | sed -n '3p')
22
+
23
+ if [ -z "$CWD" ] || [ -z "$SESSION_ID" ]; then
24
+ exit 0
25
+ fi
26
+
27
+ PROJECT=$(basename "$CWD")
28
+ QUEUE_DIR="$HOME/.promptline/queues/$PROJECT"
29
+ QUEUE_FILE="$QUEUE_DIR/$SESSION_ID.json"
30
+
31
+ mkdir -p "$QUEUE_DIR"
32
+
33
+ export QUEUE_FILE SESSION_ID CWD PROJECT TRANSCRIPT_PATH
34
+
35
+ python3 << 'PYEOF'
36
+ import json, os, tempfile
37
+ from datetime import datetime, timezone
38
+
39
+ def atomic_write(path, obj):
40
+ dir_name = os.path.dirname(path)
41
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
42
+ try:
43
+ with os.fdopen(fd, "w") as f:
44
+ json.dump(obj, f, indent=2)
45
+ os.replace(tmp_path, path)
46
+ except Exception:
47
+ try: os.unlink(tmp_path)
48
+ except OSError: pass
49
+ raise
50
+
51
+ def extract_session_name(transcript_path, max_len=50):
52
+ """Extract first user message from transcript JSONL as session name."""
53
+ if not transcript_path or not os.path.isfile(transcript_path):
54
+ return None
55
+ try:
56
+ with open(transcript_path, "r") as f:
57
+ for line in f:
58
+ try:
59
+ entry = json.loads(line.strip())
60
+ if entry.get("type") == "user":
61
+ msg = entry.get("message", {})
62
+ content = msg.get("content", "")
63
+ if isinstance(content, str) and content.strip():
64
+ text = content.strip().replace("\n", " ")
65
+ return text[:max_len] + "..." if len(text) > max_len else text
66
+ elif isinstance(content, list):
67
+ for part in content:
68
+ if isinstance(part, dict) and part.get("type") == "text":
69
+ text = part.get("text", "").strip().replace("\n", " ")
70
+ if text:
71
+ return text[:max_len] + "..." if len(text) > max_len else text
72
+ except (json.JSONDecodeError, KeyError):
73
+ continue
74
+ except (IOError, OSError):
75
+ pass
76
+ return None
77
+
78
+ queue_file = os.environ["QUEUE_FILE"]
79
+ session_id = os.environ["SESSION_ID"]
80
+ cwd = os.environ["CWD"]
81
+ project = os.environ["PROJECT"]
82
+ transcript_path = os.environ.get("TRANSCRIPT_PATH", "")
83
+ now = datetime.now(timezone.utc).isoformat()
84
+
85
+ if os.path.isfile(queue_file):
86
+ try:
87
+ with open(queue_file, "r") as f:
88
+ data = json.load(f)
89
+ data["lastActivity"] = now
90
+ if not data.get("sessionName"):
91
+ data["sessionName"] = extract_session_name(transcript_path)
92
+ atomic_write(queue_file, data)
93
+ except (json.JSONDecodeError, IOError):
94
+ pass
95
+ else:
96
+ session_name = extract_session_name(transcript_path)
97
+ data = {
98
+ "sessionId": session_id,
99
+ "project": project,
100
+ "directory": cwd,
101
+ "sessionName": session_name,
102
+ "prompts": [],
103
+ "startedAt": now,
104
+ "lastActivity": now,
105
+ "currentPromptId": None,
106
+ "completedAt": None,
107
+ "closedAt": None,
108
+ }
109
+ atomic_write(queue_file, data)
110
+
111
+ PYEOF
112
+
113
+ exit 0