@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelbyte-software/pixcode",
3
- "version": "1.53.1",
3
+ "version": "1.53.3",
4
4
  "description": "Self-hosted AI coding agent control room for Claude Code, Cursor CLI, OpenAI Codex, Gemini CLI, Qwen Code, and OpenCode with chat, files, shell, Git, orchestration, API keys, Telegram, MCP, plugins, themes, and desktop/server deployment.",
5
5
  "type": "module",
6
6
  "main": "dist-server/server/index.js",
package/server/index.js CHANGED
@@ -273,6 +273,59 @@ function getActiveUpdateJob() {
273
273
  return null;
274
274
  }
275
275
 
276
+ function countByProvider(items) {
277
+ return items.reduce((counts, item) => {
278
+ const provider = item?.provider || 'unknown';
279
+ counts[provider] = (counts[provider] || 0) + 1;
280
+ return counts;
281
+ }, {});
282
+ }
283
+
284
+ function getActiveProviderWorkSummary() {
285
+ const sessions = [
286
+ ...getActiveClaudeSDKSessions().map((id) => ({ id, provider: 'claude' })),
287
+ ...getActiveCursorSessions().map((session) => ({ ...session, provider: 'cursor' })),
288
+ ...getActiveCodexSessions().map((session) => ({ ...session, provider: 'codex' })),
289
+ ...getActiveGeminiSessions().map((session) => ({ ...session, provider: 'gemini' })),
290
+ ...getActiveQwenSessions().map((session) => ({ ...session, provider: 'qwen' })),
291
+ ...getActiveOpencodeSessions().map((session) => ({ ...session, provider: 'opencode' })),
292
+ ];
293
+
294
+ return {
295
+ total: sessions.length,
296
+ byProvider: countByProvider(sessions),
297
+ };
298
+ }
299
+
300
+ function getActivePtyWorkSummary() {
301
+ const activePtys = Array.from(ptySessionsMap.values())
302
+ .filter((session) => session?.pty && session.lifecycleState === 'running')
303
+ .map((session) => ({
304
+ provider: session.isPlainShell ? 'plain-shell' : (session.provider || 'unknown'),
305
+ connected: Boolean(session.ws && session.ws.readyState === WebSocket.OPEN),
306
+ }));
307
+
308
+ return {
309
+ total: activePtys.length,
310
+ connected: activePtys.filter((session) => session.connected).length,
311
+ detached: activePtys.filter((session) => !session.connected).length,
312
+ byProvider: countByProvider(activePtys),
313
+ };
314
+ }
315
+
316
+ function getActiveWorkSummary() {
317
+ const pty = getActivePtyWorkSummary();
318
+ const agents = getActiveProviderWorkSummary();
319
+ const total = pty.total + agents.total;
320
+
321
+ return {
322
+ hasActiveWork: total > 0,
323
+ total,
324
+ pty,
325
+ agents,
326
+ };
327
+ }
328
+
276
329
  async function readLatestPixcodePackageMetadata() {
277
330
  const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
278
331
  if (!registryRes.ok) throw new Error(`Registry returned HTTP ${registryRes.status}`);
@@ -375,8 +428,10 @@ async function runCommandUpdateJob(job, updateCommand, updateCwd) {
375
428
  cwd: updateCwd,
376
429
  env: process.env,
377
430
  shell: true,
431
+ detached: true,
378
432
  stdio: ['ignore', 'pipe', 'pipe'],
379
433
  });
434
+ try { child.unref(); } catch { /* noop */ }
380
435
  child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
381
436
  child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
382
437
  child.on('error', reject);
@@ -695,7 +750,108 @@ async function setupProjectsWatcher() {
695
750
  // `project_files_updated` events to subscribed clients only, letting the
696
751
  // explorer refresh automatically without HTTP polling.
697
752
  const WORKSPACE_WATCHER_DEBOUNCE_MS = 800;
698
- const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, debounceTimer, pendingEvent, rootPath }
753
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 512 * 1024;
754
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 800;
755
+ const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, debounceTimer, pendingEvent, rootPath, fileSnapshots }
756
+
757
+ function runWorkspaceCommand(command, args, cwd) {
758
+ return new Promise((resolve, reject) => {
759
+ const child = spawn(command, args, {
760
+ cwd,
761
+ shell: false,
762
+ stdio: ['ignore', 'pipe', 'pipe'],
763
+ });
764
+ let stdout = '';
765
+ let stderr = '';
766
+
767
+ child.stdout?.on('data', (chunk) => {
768
+ stdout += chunk.toString();
769
+ });
770
+ child.stderr?.on('data', (chunk) => {
771
+ stderr += chunk.toString();
772
+ });
773
+ child.on('error', reject);
774
+ child.on('close', (code) => {
775
+ if (code === 0) {
776
+ resolve({ stdout, stderr });
777
+ return;
778
+ }
779
+
780
+ reject(new Error(stderr || `${command} exited with code ${code}`));
781
+ });
782
+ });
783
+ }
784
+
785
+ function parseGitPorcelainZ(output) {
786
+ const entries = [];
787
+ const parts = String(output || '').split('\0').filter(Boolean);
788
+
789
+ for (let index = 0; index < parts.length; index += 1) {
790
+ const entry = parts[index];
791
+ if (entry.length < 4) {
792
+ continue;
793
+ }
794
+
795
+ const status = entry.slice(0, 2);
796
+ const filePath = entry.slice(3).replace(/\\/g, '/');
797
+ if (filePath) {
798
+ entries.push(filePath);
799
+ }
800
+
801
+ if (status[0] === 'R' || status[0] === 'C') {
802
+ index += 1;
803
+ }
804
+ }
805
+
806
+ return entries;
807
+ }
808
+
809
+ function normalizeWorkspaceRelativePath(rootPath, filePath) {
810
+ const relativePath = path.relative(rootPath, filePath).replace(/\\/g, '/');
811
+ if (!relativePath || relativePath.startsWith('../') || relativePath === '..' || path.isAbsolute(relativePath)) {
812
+ return null;
813
+ }
814
+
815
+ return relativePath;
816
+ }
817
+
818
+ async function readWorkspaceTextSnapshot(filePath) {
819
+ try {
820
+ const stats = await fsPromises.stat(filePath);
821
+ if (!stats.isFile() || stats.size > WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES) {
822
+ return null;
823
+ }
824
+
825
+ const buffer = await fsPromises.readFile(filePath);
826
+ if (buffer.includes(0)) {
827
+ return null;
828
+ }
829
+
830
+ return buffer.toString('utf8');
831
+ } catch (error) {
832
+ if (error?.code === 'ENOENT') {
833
+ return '';
834
+ }
835
+
836
+ return null;
837
+ }
838
+ }
839
+
840
+ async function initializeWorkspaceDirtySnapshots(rootPath, fileSnapshots) {
841
+ try {
842
+ const { stdout } = await runWorkspaceCommand('git', ['status', '--porcelain', '-z'], rootPath);
843
+ const changedPaths = parseGitPorcelainZ(stdout).slice(0, WORKSPACE_DIFF_SNAPSHOT_MAX_FILES);
844
+
845
+ await Promise.all(changedPaths.map(async (relativePath) => {
846
+ const content = await readWorkspaceTextSnapshot(path.join(rootPath, relativePath));
847
+ if (typeof content === 'string') {
848
+ fileSnapshots.set(relativePath, content);
849
+ }
850
+ }));
851
+ } catch {
852
+ // Non-git workspaces still get event-time snapshots after the first change.
853
+ }
854
+ }
699
855
 
700
856
  async function subscribeToWorkspace(ws, projectName) {
701
857
  if (!projectName || typeof projectName !== 'string') return;
@@ -722,11 +878,48 @@ async function subscribeToWorkspace(ws, projectName) {
722
878
  debounceTimer: null,
723
879
  pendingEvent: null,
724
880
  rootPath,
881
+ fileSnapshots: new Map(),
725
882
  };
726
883
  workspaceWatchers.set(projectName, entry);
884
+ await initializeWorkspaceDirtySnapshots(rootPath, entry.fileSnapshots);
885
+
886
+ const broadcastFileUpdate = async (eventType, filePath) => {
887
+ const relativePath = filePath ? normalizeWorkspaceRelativePath(rootPath, filePath) : null;
888
+ const previousContent = relativePath ? entry.fileSnapshots.get(relativePath) : undefined;
889
+ let currentContent;
890
+
891
+ if (relativePath && !eventType.endsWith('Dir')) {
892
+ if (eventType === 'unlink') {
893
+ currentContent = '';
894
+ entry.fileSnapshots.delete(relativePath);
895
+ } else {
896
+ const snapshot = await readWorkspaceTextSnapshot(filePath);
897
+ if (typeof snapshot === 'string') {
898
+ currentContent = snapshot;
899
+ entry.fileSnapshots.set(relativePath, snapshot);
900
+ }
901
+ }
902
+ }
903
+
904
+ const nextEvent = {
905
+ eventType,
906
+ filePath,
907
+ changedFile: relativePath,
908
+ oldContent: typeof previousContent === 'string'
909
+ ? previousContent
910
+ : eventType === 'add'
911
+ ? ''
912
+ : undefined,
913
+ currentContent,
914
+ };
915
+
916
+ entry.pendingEvent = entry.pendingEvent?.changedFile === relativePath
917
+ ? {
918
+ ...nextEvent,
919
+ oldContent: entry.pendingEvent.oldContent ?? nextEvent.oldContent,
920
+ }
921
+ : nextEvent;
727
922
 
728
- const broadcastFileUpdate = (eventType, filePath) => {
729
- entry.pendingEvent = { eventType, filePath };
730
923
  if (entry.debounceTimer) {
731
924
  clearTimeout(entry.debounceTimer);
732
925
  }
@@ -734,11 +927,23 @@ async function subscribeToWorkspace(ws, projectName) {
734
927
  entry.debounceTimer = null;
735
928
  const pending = entry.pendingEvent || {};
736
929
  entry.pendingEvent = null;
930
+ const hasSnapshotDiff = (
931
+ typeof pending.oldContent === 'string'
932
+ && typeof pending.currentContent === 'string'
933
+ && pending.oldContent !== pending.currentContent
934
+ );
737
935
  const message = JSON.stringify({
738
936
  type: 'project_files_updated',
739
937
  projectName,
740
938
  changeType: pending.eventType || 'change',
741
- changedFile: pending.filePath ? path.relative(rootPath, pending.filePath) : null,
939
+ changedFile: pending.changedFile ?? (pending.filePath ? path.relative(rootPath, pending.filePath) : null),
940
+ ...(hasSnapshotDiff
941
+ ? {
942
+ oldContent: pending.oldContent,
943
+ currentContent: pending.currentContent,
944
+ diffSource: 'workspace-snapshot',
945
+ }
946
+ : {}),
742
947
  timestamp: new Date().toISOString(),
743
948
  });
744
949
  entry.subscribers.forEach((client) => {
@@ -764,11 +969,11 @@ async function subscribeToWorkspace(ws, projectName) {
764
969
  });
765
970
 
766
971
  watcher
767
- .on('add', (filePath) => broadcastFileUpdate('add', filePath))
768
- .on('change', (filePath) => broadcastFileUpdate('change', filePath))
769
- .on('unlink', (filePath) => broadcastFileUpdate('unlink', filePath))
770
- .on('addDir', (dirPath) => broadcastFileUpdate('addDir', dirPath))
771
- .on('unlinkDir', (dirPath) => broadcastFileUpdate('unlinkDir', dirPath))
972
+ .on('add', (filePath) => void broadcastFileUpdate('add', filePath))
973
+ .on('change', (filePath) => void broadcastFileUpdate('change', filePath))
974
+ .on('unlink', (filePath) => void broadcastFileUpdate('unlink', filePath))
975
+ .on('addDir', (dirPath) => void broadcastFileUpdate('addDir', dirPath))
976
+ .on('unlinkDir', (dirPath) => void broadcastFileUpdate('unlinkDir', dirPath))
772
977
  .on('error', (error) => {
773
978
  console.error(`[ERROR] Workspace watcher error for ${projectName}:`, error);
774
979
  });
@@ -1485,6 +1690,7 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1485
1690
  success: true,
1486
1691
  state: readSystemUpdateState(),
1487
1692
  activeJob: snapshotUpdateJob(getActiveUpdateJob()),
1693
+ activeWork: getActiveWorkSummary(),
1488
1694
  currentVersion: SERVER_VERSION,
1489
1695
  installMode,
1490
1696
  capabilities: {
@@ -1885,6 +2091,16 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
1885
2091
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1886
2092
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1887
2093
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
2094
+ const forceRestart = req.body?.force === true || req.query.force === 'true';
2095
+ const activeWork = getActiveWorkSummary();
2096
+ if (!forceRestart && activeWork.hasActiveWork) {
2097
+ return res.status(409).json({
2098
+ success: false,
2099
+ error: 'Active terminal or agent sessions are running. Confirm restart to interrupt them.',
2100
+ activeWork,
2101
+ });
2102
+ }
2103
+
1888
2104
  res.json({
1889
2105
  success: true,
1890
2106
  version: SERVER_VERSION,