quilltap 3.3.0-dev.95 → 4.0.0-dev.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/bin/quilltap.js +295 -0
- package/package.json +4 -4
package/bin/quilltap.js
CHANGED
|
@@ -499,6 +499,281 @@ async function loadDbKey(dataDir, passphrase) {
|
|
|
499
499
|
return tryDecrypt(passphrase);
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
// ============================================================================
|
|
503
|
+
// Instance Lock CLI Commands
|
|
504
|
+
// ============================================================================
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Check whether a PID is alive using signal 0.
|
|
508
|
+
*/
|
|
509
|
+
function isPidAlive(pid) {
|
|
510
|
+
try {
|
|
511
|
+
process.kill(pid, 0);
|
|
512
|
+
return true;
|
|
513
|
+
} catch (err) {
|
|
514
|
+
return err.code === 'EPERM'; // EPERM = exists but no permission
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Verify a PID looks like a Node/Quilltap process (best-effort).
|
|
520
|
+
*/
|
|
521
|
+
function verifyPidIsNode(pid, expectedArgv0) {
|
|
522
|
+
try {
|
|
523
|
+
if (process.platform === 'linux') {
|
|
524
|
+
try {
|
|
525
|
+
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
526
|
+
const cmd = cmdline.split('\0')[0] || '';
|
|
527
|
+
return /node|electron|quilltap|next-server/i.test(cmd);
|
|
528
|
+
} catch {
|
|
529
|
+
return true; // Can't read — assume match
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (process.platform === 'darwin') {
|
|
533
|
+
const { execSync } = require('child_process');
|
|
534
|
+
const output = execSync(`ps -p ${pid} -o comm=`, {
|
|
535
|
+
encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
536
|
+
}).trim();
|
|
537
|
+
return /node|electron|quilltap|next-server/i.test(output);
|
|
538
|
+
}
|
|
539
|
+
if (process.platform === 'win32') {
|
|
540
|
+
const { execSync } = require('child_process');
|
|
541
|
+
const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
|
|
542
|
+
encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
543
|
+
}).trim();
|
|
544
|
+
return /node|electron|quilltap|next-server/i.test(output);
|
|
545
|
+
}
|
|
546
|
+
return true;
|
|
547
|
+
} catch {
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Handle --lock-status, --lock-clean, --lock-override commands.
|
|
554
|
+
*/
|
|
555
|
+
function handleLockCommand(dataDir, opts) {
|
|
556
|
+
const lockPath = path.join(dataDir, 'quilltap.lock');
|
|
557
|
+
const hostname = require('os').hostname();
|
|
558
|
+
|
|
559
|
+
// Read lock file
|
|
560
|
+
let lock = null;
|
|
561
|
+
try {
|
|
562
|
+
if (fs.existsSync(lockPath)) {
|
|
563
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
564
|
+
lock = JSON.parse(raw);
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (opts.lockStatus) {
|
|
568
|
+
console.log('Lock file exists but is corrupt or unreadable.');
|
|
569
|
+
console.log(` Path: ${lockPath}`);
|
|
570
|
+
if (opts.lockClean) {
|
|
571
|
+
fs.unlinkSync(lockPath);
|
|
572
|
+
console.log(' Removed corrupt lock file.');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// --lock-status: display current lock state
|
|
579
|
+
if (opts.lockStatus) {
|
|
580
|
+
if (!lock) {
|
|
581
|
+
console.log('No instance lock found. Database is not currently claimed.');
|
|
582
|
+
console.log(` Lock path: ${lockPath}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const sameHost = lock.hostname === hostname;
|
|
587
|
+
const alive = sameHost && isPidAlive(lock.pid);
|
|
588
|
+
const isNode = alive ? verifyPidIsNode(lock.pid, lock.processArgv0 || '') : false;
|
|
589
|
+
|
|
590
|
+
// Status line
|
|
591
|
+
let status;
|
|
592
|
+
if (alive && isNode) {
|
|
593
|
+
status = '\x1b[32mACTIVE\x1b[0m (process confirmed running)';
|
|
594
|
+
} else if (alive && !isNode) {
|
|
595
|
+
status = '\x1b[33mSUSPECT\x1b[0m (PID alive but does not look like Quilltap — possible PID reuse)';
|
|
596
|
+
} else if (!sameHost) {
|
|
597
|
+
// Different hostname — could be a VM/container on this machine
|
|
598
|
+
const isVMOrContainer = ['docker', 'lima', 'wsl2'].includes(lock.environment);
|
|
599
|
+
const heartbeatAgeMs = lock.lastHeartbeat
|
|
600
|
+
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
601
|
+
: Infinity;
|
|
602
|
+
const heartbeatFreshMs = 5 * 60 * 1000;
|
|
603
|
+
|
|
604
|
+
if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
|
|
605
|
+
const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
|
|
606
|
+
status = `\x1b[32mACTIVE (${lock.environment}, heartbeat ${ageStr} ago)\x1b[0m`;
|
|
607
|
+
} else if (isVMOrContainer) {
|
|
608
|
+
status = `\x1b[33mSTALE (${lock.environment}, no recent heartbeat)\x1b[0m — will be auto-claimed on next startup`;
|
|
609
|
+
} else {
|
|
610
|
+
status = '\x1b[33mSTALE (different host)\x1b[0m — will be auto-claimed on next startup';
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
status = '\x1b[31mSTALE (process dead)\x1b[0m — will be auto-claimed on next startup';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
console.log(`Instance Lock Status: ${status}`);
|
|
617
|
+
console.log();
|
|
618
|
+
console.log(` PID: ${lock.pid}`);
|
|
619
|
+
console.log(` Hostname: ${lock.hostname}${sameHost ? ' (this host)' : ' (different host)'}`);
|
|
620
|
+
console.log(` Environment: ${lock.environment || 'unknown'}`);
|
|
621
|
+
console.log(` Process: ${lock.processTitle || 'unknown'}`);
|
|
622
|
+
console.log(` Started: ${lock.startedAt || 'unknown'}`);
|
|
623
|
+
|
|
624
|
+
// Show heartbeat age if available
|
|
625
|
+
if (lock.lastHeartbeat) {
|
|
626
|
+
const heartbeatAge = Math.round((Date.now() - new Date(lock.lastHeartbeat).getTime()) / 1000);
|
|
627
|
+
let heartbeatDisplay;
|
|
628
|
+
if (heartbeatAge < 120) {
|
|
629
|
+
heartbeatDisplay = `${heartbeatAge}s ago`;
|
|
630
|
+
} else if (heartbeatAge < 7200) {
|
|
631
|
+
heartbeatDisplay = `${Math.round(heartbeatAge / 60)}m ago`;
|
|
632
|
+
} else {
|
|
633
|
+
heartbeatDisplay = `${Math.round(heartbeatAge / 3600)}h ago`;
|
|
634
|
+
}
|
|
635
|
+
// Warn if heartbeat is older than 5 minutes (process may be hung)
|
|
636
|
+
if (alive && heartbeatAge > 300) {
|
|
637
|
+
heartbeatDisplay = `\x1b[33m${heartbeatDisplay} (stale — process may be hung)\x1b[0m`;
|
|
638
|
+
}
|
|
639
|
+
console.log(` Heartbeat: ${heartbeatDisplay}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
console.log(` Lock file: ${lockPath}`);
|
|
643
|
+
|
|
644
|
+
if (lock.history && lock.history.length > 0) {
|
|
645
|
+
console.log();
|
|
646
|
+
console.log(` Recent history (${lock.history.length} entries):`);
|
|
647
|
+
const recent = lock.history.slice(-10);
|
|
648
|
+
for (const entry of recent) {
|
|
649
|
+
const ts = entry.timestamp ? entry.timestamp.replace('T', ' ').replace(/\.\d+Z$/, 'Z') : '?';
|
|
650
|
+
const detail = entry.detail ? ` — ${entry.detail}` : '';
|
|
651
|
+
console.log(` [${ts}] ${entry.event} (PID ${entry.pid})${detail}`);
|
|
652
|
+
}
|
|
653
|
+
if (lock.history.length > 10) {
|
|
654
|
+
console.log(` ... and ${lock.history.length - 10} earlier entries`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// --lock-clean: remove stale locks only
|
|
661
|
+
if (opts.lockClean) {
|
|
662
|
+
if (!lock) {
|
|
663
|
+
console.log('No lock file found. Nothing to clean.');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const sameHost = lock.hostname === hostname;
|
|
668
|
+
const alive = sameHost && isPidAlive(lock.pid);
|
|
669
|
+
|
|
670
|
+
if (alive) {
|
|
671
|
+
const isNode = verifyPidIsNode(lock.pid, lock.processArgv0 || '');
|
|
672
|
+
if (isNode) {
|
|
673
|
+
console.log(`Lock is held by a live Quilltap process (PID ${lock.pid}). Cannot clean.`);
|
|
674
|
+
console.log('Stop the running instance first, or use --lock-override to force.');
|
|
675
|
+
process.exit(1);
|
|
676
|
+
} else {
|
|
677
|
+
console.log(`Lock references PID ${lock.pid} which is alive but does NOT look like a Quilltap process.`);
|
|
678
|
+
console.log('This is likely a stale lock with a reused PID. Removing.');
|
|
679
|
+
}
|
|
680
|
+
} else if (!sameHost) {
|
|
681
|
+
// Different hostname — check if it's a VM/container with a recent heartbeat
|
|
682
|
+
const isVMOrContainer = ['docker', 'lima', 'wsl2'].includes(lock.environment);
|
|
683
|
+
const heartbeatAgeMs = lock.lastHeartbeat
|
|
684
|
+
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
685
|
+
: Infinity;
|
|
686
|
+
const heartbeatFreshMs = 5 * 60 * 1000;
|
|
687
|
+
|
|
688
|
+
if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
|
|
689
|
+
const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
|
|
690
|
+
console.log(`Lock is held by a live ${lock.environment} instance (heartbeat ${ageStr} ago). Cannot clean.`);
|
|
691
|
+
console.log('Stop the other instance first, or use --lock-override to force.');
|
|
692
|
+
process.exit(1);
|
|
693
|
+
} else if (isVMOrContainer) {
|
|
694
|
+
console.log(`Lock was held by ${lock.environment} (${lock.hostname}) with no recent heartbeat. Removing stale lock.`);
|
|
695
|
+
} else {
|
|
696
|
+
console.log(`Lock was held by a different host (${lock.hostname}). Removing stale lock.`);
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
console.log(`Lock was held by PID ${lock.pid} which is no longer running. Removing stale lock.`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Write a final history entry before deleting
|
|
703
|
+
if (!lock.history) lock.history = [];
|
|
704
|
+
lock.history.push({
|
|
705
|
+
event: 'stale-claimed',
|
|
706
|
+
pid: process.pid,
|
|
707
|
+
hostname: hostname,
|
|
708
|
+
timestamp: new Date().toISOString(),
|
|
709
|
+
detail: `Cleaned via CLI (quilltap db --lock-clean)`,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// Write final state then remove
|
|
713
|
+
try {
|
|
714
|
+
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
|
|
715
|
+
} catch { /* best effort */ }
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
fs.unlinkSync(lockPath);
|
|
719
|
+
console.log('Lock file removed.');
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.error(`Failed to remove lock file: ${err.message}`);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --lock-override: forcibly claim the lock
|
|
728
|
+
if (opts.lockOverride) {
|
|
729
|
+
if (!lock) {
|
|
730
|
+
console.log('No lock file found. Nothing to override.');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const sameHost = lock.hostname === hostname;
|
|
735
|
+
const alive = sameHost && isPidAlive(lock.pid);
|
|
736
|
+
|
|
737
|
+
if (alive) {
|
|
738
|
+
const isNode = verifyPidIsNode(lock.pid, lock.processArgv0 || '');
|
|
739
|
+
if (!isNode) {
|
|
740
|
+
console.error(`Lock override rejected: PID ${lock.pid} is alive but does not appear to be`);
|
|
741
|
+
console.error('a Quilltap/Node process. The PID may have been reused. Verify manually.');
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
console.log(`WARNING: Overriding lock held by live process (PID ${lock.pid}, ${lock.environment || 'unknown'}).`);
|
|
745
|
+
console.log('The other instance may corrupt the database if it is still writing.');
|
|
746
|
+
} else {
|
|
747
|
+
console.log(`Overriding stale lock (PID ${lock.pid} is no longer running).`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Record override in history
|
|
751
|
+
if (!lock.history) lock.history = [];
|
|
752
|
+
lock.history.push({
|
|
753
|
+
event: 'override',
|
|
754
|
+
pid: process.pid,
|
|
755
|
+
hostname: hostname,
|
|
756
|
+
timestamp: new Date().toISOString(),
|
|
757
|
+
detail: `Manual override via CLI (quilltap db --lock-override)` +
|
|
758
|
+
(alive ? ` — overriding live PID ${lock.pid}` : ` — PID ${lock.pid} was dead`),
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// Write final state then remove so next startup gets a clean acquire
|
|
762
|
+
try {
|
|
763
|
+
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
|
|
764
|
+
} catch { /* best effort */ }
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
fs.unlinkSync(lockPath);
|
|
768
|
+
console.log('Lock file removed. Next Quilltap startup will acquire a fresh lock.');
|
|
769
|
+
} catch (err) {
|
|
770
|
+
console.error(`Failed to remove lock file: ${err.message}`);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
502
777
|
function printDbHelp() {
|
|
503
778
|
console.log(`
|
|
504
779
|
Quilltap Database Tool
|
|
@@ -516,6 +791,11 @@ Options:
|
|
|
516
791
|
--passphrase <pass> Provide passphrase for encrypted .dbkey
|
|
517
792
|
-h, --help Show this help
|
|
518
793
|
|
|
794
|
+
Instance Lock Commands:
|
|
795
|
+
--lock-status Show the current instance lock state
|
|
796
|
+
--lock-clean Remove stale locks (dead processes only)
|
|
797
|
+
--lock-override Forcibly claim the lock for this process
|
|
798
|
+
|
|
519
799
|
If a passphrase is required and not provided via --passphrase, the tool
|
|
520
800
|
will check the QUILLTAP_DB_PASSPHRASE environment variable, then prompt
|
|
521
801
|
interactively (with hidden input) if a TTY is available.
|
|
@@ -526,6 +806,8 @@ Examples:
|
|
|
526
806
|
quilltap db --count messages
|
|
527
807
|
quilltap db --repl
|
|
528
808
|
quilltap db --llm-logs --tables
|
|
809
|
+
quilltap db --lock-status
|
|
810
|
+
quilltap db --lock-clean
|
|
529
811
|
QUILLTAP_DB_PASSPHRASE=secret quilltap db --tables
|
|
530
812
|
`);
|
|
531
813
|
}
|
|
@@ -539,6 +821,9 @@ async function dbCommand(args) {
|
|
|
539
821
|
let repl = false;
|
|
540
822
|
let sql = '';
|
|
541
823
|
let showHelp = false;
|
|
824
|
+
let lockStatus = false;
|
|
825
|
+
let lockClean = false;
|
|
826
|
+
let lockOverride = false;
|
|
542
827
|
|
|
543
828
|
let i = 0;
|
|
544
829
|
while (i < args.length) {
|
|
@@ -550,6 +835,9 @@ async function dbCommand(args) {
|
|
|
550
835
|
case '--count': countTable = args[++i]; break;
|
|
551
836
|
case '--repl': repl = true; break;
|
|
552
837
|
case '--help': case '-h': showHelp = true; break;
|
|
838
|
+
case '--lock-status': lockStatus = true; break;
|
|
839
|
+
case '--lock-clean': lockClean = true; break;
|
|
840
|
+
case '--lock-override': lockOverride = true; break;
|
|
553
841
|
default:
|
|
554
842
|
if (args[i].startsWith('-')) {
|
|
555
843
|
console.error(`Unknown option: ${args[i]}`);
|
|
@@ -567,6 +855,13 @@ async function dbCommand(args) {
|
|
|
567
855
|
}
|
|
568
856
|
|
|
569
857
|
const dataDir = resolveDataDir(dataDirOverride);
|
|
858
|
+
|
|
859
|
+
// ---- Instance lock commands (no database open required) ----
|
|
860
|
+
if (lockStatus || lockClean || lockOverride) {
|
|
861
|
+
handleLockCommand(dataDir, { lockStatus, lockClean, lockOverride });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
570
865
|
const dbFilename = useLlmLogs ? 'quilltap-llm-logs.db' : 'quilltap.db';
|
|
571
866
|
const dbPath = path.join(dataDir, dbFilename);
|
|
572
867
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-dev.0",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"better-sqlite3-multiple-ciphers": "^12.
|
|
36
|
+
"better-sqlite3-multiple-ciphers": "^12.8.0",
|
|
37
37
|
"sharp": "^0.34.5",
|
|
38
|
-
"tar": "^7.
|
|
39
|
-
"yauzl": "^3.2.
|
|
38
|
+
"tar": "^7.5.13",
|
|
39
|
+
"yauzl": "^3.2.1"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"node": ">=22.0.0"
|