@pixelbyte-software/pixcode 1.53.1 → 1.53.3

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.
@@ -231,6 +231,52 @@ function getActiveUpdateJob() {
231
231
  }
232
232
  return null;
233
233
  }
234
+ function countByProvider(items) {
235
+ return items.reduce((counts, item) => {
236
+ const provider = item?.provider || 'unknown';
237
+ counts[provider] = (counts[provider] || 0) + 1;
238
+ return counts;
239
+ }, {});
240
+ }
241
+ function getActiveProviderWorkSummary() {
242
+ const sessions = [
243
+ ...getActiveClaudeSDKSessions().map((id) => ({ id, provider: 'claude' })),
244
+ ...getActiveCursorSessions().map((session) => ({ ...session, provider: 'cursor' })),
245
+ ...getActiveCodexSessions().map((session) => ({ ...session, provider: 'codex' })),
246
+ ...getActiveGeminiSessions().map((session) => ({ ...session, provider: 'gemini' })),
247
+ ...getActiveQwenSessions().map((session) => ({ ...session, provider: 'qwen' })),
248
+ ...getActiveOpencodeSessions().map((session) => ({ ...session, provider: 'opencode' })),
249
+ ];
250
+ return {
251
+ total: sessions.length,
252
+ byProvider: countByProvider(sessions),
253
+ };
254
+ }
255
+ function getActivePtyWorkSummary() {
256
+ const activePtys = Array.from(ptySessionsMap.values())
257
+ .filter((session) => session?.pty && session.lifecycleState === 'running')
258
+ .map((session) => ({
259
+ provider: session.isPlainShell ? 'plain-shell' : (session.provider || 'unknown'),
260
+ connected: Boolean(session.ws && session.ws.readyState === WebSocket.OPEN),
261
+ }));
262
+ return {
263
+ total: activePtys.length,
264
+ connected: activePtys.filter((session) => session.connected).length,
265
+ detached: activePtys.filter((session) => !session.connected).length,
266
+ byProvider: countByProvider(activePtys),
267
+ };
268
+ }
269
+ function getActiveWorkSummary() {
270
+ const pty = getActivePtyWorkSummary();
271
+ const agents = getActiveProviderWorkSummary();
272
+ const total = pty.total + agents.total;
273
+ return {
274
+ hasActiveWork: total > 0,
275
+ total,
276
+ pty,
277
+ agents,
278
+ };
279
+ }
234
280
  async function readLatestPixcodePackageMetadata() {
235
281
  const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
236
282
  if (!registryRes.ok)
@@ -329,8 +375,13 @@ async function runCommandUpdateJob(job, updateCommand, updateCwd) {
329
375
  cwd: updateCwd,
330
376
  env: process.env,
331
377
  shell: true,
378
+ detached: true,
332
379
  stdio: ['ignore', 'pipe', 'pipe'],
333
380
  });
381
+ try {
382
+ child.unref();
383
+ }
384
+ catch { /* noop */ }
334
385
  child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
335
386
  child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
336
387
  child.on('error', reject);
@@ -624,7 +675,94 @@ async function setupProjectsWatcher() {
624
675
  // `project_files_updated` events to subscribed clients only, letting the
625
676
  // explorer refresh automatically without HTTP polling.
626
677
  const WORKSPACE_WATCHER_DEBOUNCE_MS = 800;
627
- const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, debounceTimer, pendingEvent, rootPath }
678
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 512 * 1024;
679
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 800;
680
+ const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, debounceTimer, pendingEvent, rootPath, fileSnapshots }
681
+ function runWorkspaceCommand(command, args, cwd) {
682
+ return new Promise((resolve, reject) => {
683
+ const child = spawn(command, args, {
684
+ cwd,
685
+ shell: false,
686
+ stdio: ['ignore', 'pipe', 'pipe'],
687
+ });
688
+ let stdout = '';
689
+ let stderr = '';
690
+ child.stdout?.on('data', (chunk) => {
691
+ stdout += chunk.toString();
692
+ });
693
+ child.stderr?.on('data', (chunk) => {
694
+ stderr += chunk.toString();
695
+ });
696
+ child.on('error', reject);
697
+ child.on('close', (code) => {
698
+ if (code === 0) {
699
+ resolve({ stdout, stderr });
700
+ return;
701
+ }
702
+ reject(new Error(stderr || `${command} exited with code ${code}`));
703
+ });
704
+ });
705
+ }
706
+ function parseGitPorcelainZ(output) {
707
+ const entries = [];
708
+ const parts = String(output || '').split('\0').filter(Boolean);
709
+ for (let index = 0; index < parts.length; index += 1) {
710
+ const entry = parts[index];
711
+ if (entry.length < 4) {
712
+ continue;
713
+ }
714
+ const status = entry.slice(0, 2);
715
+ const filePath = entry.slice(3).replace(/\\/g, '/');
716
+ if (filePath) {
717
+ entries.push(filePath);
718
+ }
719
+ if (status[0] === 'R' || status[0] === 'C') {
720
+ index += 1;
721
+ }
722
+ }
723
+ return entries;
724
+ }
725
+ function normalizeWorkspaceRelativePath(rootPath, filePath) {
726
+ const relativePath = path.relative(rootPath, filePath).replace(/\\/g, '/');
727
+ if (!relativePath || relativePath.startsWith('../') || relativePath === '..' || path.isAbsolute(relativePath)) {
728
+ return null;
729
+ }
730
+ return relativePath;
731
+ }
732
+ async function readWorkspaceTextSnapshot(filePath) {
733
+ try {
734
+ const stats = await fsPromises.stat(filePath);
735
+ if (!stats.isFile() || stats.size > WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES) {
736
+ return null;
737
+ }
738
+ const buffer = await fsPromises.readFile(filePath);
739
+ if (buffer.includes(0)) {
740
+ return null;
741
+ }
742
+ return buffer.toString('utf8');
743
+ }
744
+ catch (error) {
745
+ if (error?.code === 'ENOENT') {
746
+ return '';
747
+ }
748
+ return null;
749
+ }
750
+ }
751
+ async function initializeWorkspaceDirtySnapshots(rootPath, fileSnapshots) {
752
+ try {
753
+ const { stdout } = await runWorkspaceCommand('git', ['status', '--porcelain', '-z'], rootPath);
754
+ const changedPaths = parseGitPorcelainZ(stdout).slice(0, WORKSPACE_DIFF_SNAPSHOT_MAX_FILES);
755
+ await Promise.all(changedPaths.map(async (relativePath) => {
756
+ const content = await readWorkspaceTextSnapshot(path.join(rootPath, relativePath));
757
+ if (typeof content === 'string') {
758
+ fileSnapshots.set(relativePath, content);
759
+ }
760
+ }));
761
+ }
762
+ catch {
763
+ // Non-git workspaces still get event-time snapshots after the first change.
764
+ }
765
+ }
628
766
  async function subscribeToWorkspace(ws, projectName) {
629
767
  if (!projectName || typeof projectName !== 'string')
630
768
  return;
@@ -650,10 +788,44 @@ async function subscribeToWorkspace(ws, projectName) {
650
788
  debounceTimer: null,
651
789
  pendingEvent: null,
652
790
  rootPath,
791
+ fileSnapshots: new Map(),
653
792
  };
654
793
  workspaceWatchers.set(projectName, entry);
655
- const broadcastFileUpdate = (eventType, filePath) => {
656
- entry.pendingEvent = { eventType, filePath };
794
+ await initializeWorkspaceDirtySnapshots(rootPath, entry.fileSnapshots);
795
+ const broadcastFileUpdate = async (eventType, filePath) => {
796
+ const relativePath = filePath ? normalizeWorkspaceRelativePath(rootPath, filePath) : null;
797
+ const previousContent = relativePath ? entry.fileSnapshots.get(relativePath) : undefined;
798
+ let currentContent;
799
+ if (relativePath && !eventType.endsWith('Dir')) {
800
+ if (eventType === 'unlink') {
801
+ currentContent = '';
802
+ entry.fileSnapshots.delete(relativePath);
803
+ }
804
+ else {
805
+ const snapshot = await readWorkspaceTextSnapshot(filePath);
806
+ if (typeof snapshot === 'string') {
807
+ currentContent = snapshot;
808
+ entry.fileSnapshots.set(relativePath, snapshot);
809
+ }
810
+ }
811
+ }
812
+ const nextEvent = {
813
+ eventType,
814
+ filePath,
815
+ changedFile: relativePath,
816
+ oldContent: typeof previousContent === 'string'
817
+ ? previousContent
818
+ : eventType === 'add'
819
+ ? ''
820
+ : undefined,
821
+ currentContent,
822
+ };
823
+ entry.pendingEvent = entry.pendingEvent?.changedFile === relativePath
824
+ ? {
825
+ ...nextEvent,
826
+ oldContent: entry.pendingEvent.oldContent ?? nextEvent.oldContent,
827
+ }
828
+ : nextEvent;
657
829
  if (entry.debounceTimer) {
658
830
  clearTimeout(entry.debounceTimer);
659
831
  }
@@ -661,11 +833,21 @@ async function subscribeToWorkspace(ws, projectName) {
661
833
  entry.debounceTimer = null;
662
834
  const pending = entry.pendingEvent || {};
663
835
  entry.pendingEvent = null;
836
+ const hasSnapshotDiff = (typeof pending.oldContent === 'string'
837
+ && typeof pending.currentContent === 'string'
838
+ && pending.oldContent !== pending.currentContent);
664
839
  const message = JSON.stringify({
665
840
  type: 'project_files_updated',
666
841
  projectName,
667
842
  changeType: pending.eventType || 'change',
668
- changedFile: pending.filePath ? path.relative(rootPath, pending.filePath) : null,
843
+ changedFile: pending.changedFile ?? (pending.filePath ? path.relative(rootPath, pending.filePath) : null),
844
+ ...(hasSnapshotDiff
845
+ ? {
846
+ oldContent: pending.oldContent,
847
+ currentContent: pending.currentContent,
848
+ diffSource: 'workspace-snapshot',
849
+ }
850
+ : {}),
669
851
  timestamp: new Date().toISOString(),
670
852
  });
671
853
  entry.subscribers.forEach((client) => {
@@ -689,11 +871,11 @@ async function subscribeToWorkspace(ws, projectName) {
689
871
  }
690
872
  });
691
873
  watcher
692
- .on('add', (filePath) => broadcastFileUpdate('add', filePath))
693
- .on('change', (filePath) => broadcastFileUpdate('change', filePath))
694
- .on('unlink', (filePath) => broadcastFileUpdate('unlink', filePath))
695
- .on('addDir', (dirPath) => broadcastFileUpdate('addDir', dirPath))
696
- .on('unlinkDir', (dirPath) => broadcastFileUpdate('unlinkDir', dirPath))
874
+ .on('add', (filePath) => void broadcastFileUpdate('add', filePath))
875
+ .on('change', (filePath) => void broadcastFileUpdate('change', filePath))
876
+ .on('unlink', (filePath) => void broadcastFileUpdate('unlink', filePath))
877
+ .on('addDir', (dirPath) => void broadcastFileUpdate('addDir', dirPath))
878
+ .on('unlinkDir', (dirPath) => void broadcastFileUpdate('unlinkDir', dirPath))
697
879
  .on('error', (error) => {
698
880
  console.error(`[ERROR] Workspace watcher error for ${projectName}:`, error);
699
881
  });
@@ -1296,6 +1478,7 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1296
1478
  success: true,
1297
1479
  state: readSystemUpdateState(),
1298
1480
  activeJob: snapshotUpdateJob(getActiveUpdateJob()),
1481
+ activeWork: getActiveWorkSummary(),
1299
1482
  currentVersion: SERVER_VERSION,
1300
1483
  installMode,
1301
1484
  capabilities: {
@@ -1684,6 +1867,15 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
1684
1867
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1685
1868
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1686
1869
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
1870
+ const forceRestart = req.body?.force === true || req.query.force === 'true';
1871
+ const activeWork = getActiveWorkSummary();
1872
+ if (!forceRestart && activeWork.hasActiveWork) {
1873
+ return res.status(409).json({
1874
+ success: false,
1875
+ error: 'Active terminal or agent sessions are running. Confirm restart to interrupt them.',
1876
+ activeWork,
1877
+ });
1878
+ }
1687
1879
  res.json({
1688
1880
  success: true,
1689
1881
  version: SERVER_VERSION,