@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 +45 -0
- package/bin/promptline.mjs +181 -0
- package/index.html +15 -0
- package/package.json +55 -0
- package/promptline-prompt-queue.sh +173 -0
- package/promptline-session-end.sh +58 -0
- package/promptline-session-register.sh +113 -0
- package/src/App.tsx +70 -0
- package/src/api/client.ts +55 -0
- package/src/backend/queue-store.ts +172 -0
- package/src/components/AddPromptForm.tsx +131 -0
- package/src/components/ProjectDetail.tsx +136 -0
- package/src/components/PromptCard.tsx +279 -0
- package/src/components/SessionSection.tsx +164 -0
- package/src/components/Sidebar.tsx +127 -0
- package/src/components/StatusBar.tsx +71 -0
- package/src/hooks/useQueue.ts +6 -0
- package/src/hooks/useQueues.ts +53 -0
- package/src/hooks/useSSE.ts +37 -0
- package/src/index.css +34 -0
- package/src/main.tsx +10 -0
- package/src/types/queue.ts +33 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite-plugin-api.ts +307 -0
- package/vite.config.ts +14 -0
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
|