quilltap 3.3.0-dev.98 → 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.
Files changed (2) hide show
  1. package/bin/quilltap.js +295 -0
  2. 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.3.0-dev.98",
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.6.2",
36
+ "better-sqlite3-multiple-ciphers": "^12.8.0",
37
37
  "sharp": "^0.34.5",
38
- "tar": "^7.4.3",
39
- "yauzl": "^3.2.0"
38
+ "tar": "^7.5.13",
39
+ "yauzl": "^3.2.1"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=22.0.0"