tide-commander 1.131.0 → 1.133.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 (48) hide show
  1. package/dist/assets/{BossLogsModal-CWI0TeXV.js → BossLogsModal-DrdxKTb-.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-CxiQJasU.js → BossSpawnModal-B2w4kFpi.js} +1 -1
  3. package/dist/assets/{ControlsModal-BrPnWWpS.js → ControlsModal-BWYKL25r.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-D1oIAzfZ.js → DockerLogsModal-0fJudEU2.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-CHP_HStx.js → EmbeddedEditor-BDzAm2JK.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-DCn-EXjO.js → GmailOAuthSetup-CR0SPH0J.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-D26zofAR.js → GoogleOAuthSetup-BTDncXU3.js} +1 -1
  8. package/dist/assets/{IframeModal-CSN8ck7s.js → IframeModal-Csor52D5.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BU_xwMMT.js → IntegrationsPanel-qTRjzZ-I.js} +2 -2
  10. package/dist/assets/{LogViewerModal-Amod9PlS.js → LogViewerModal-BiPg_NL7.js} +1 -1
  11. package/dist/assets/{MonitoringModal-D-2zR9Oi.js → MonitoringModal-BKjwjSqZ.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-B48puyEY.js → PM2LogsModal-BAEeg_iW.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-C4IVaTMA.js → RestoreArchivedAreaModal-Hm9Osmc4.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-BYdgkm1n.js → Scene2DCanvas-T-7D8dQ_.js} +1 -1
  15. package/dist/assets/{SceneManager-DVL0xQVk.js → SceneManager-C2rng0PA.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BwCe6nFB.js → SkillsPanel-BD9RO30O.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-nSiRoxLD.js → SlackMultiInstanceSetup-CiK0r5fz.js} +1 -1
  18. package/dist/assets/{SpawnModal-DXvcL-cq.js → SpawnModal-Di4xD6Ue.js} +1 -1
  19. package/dist/assets/{StatisticsModal-Dgq-n8L6.js → StatisticsModal-Dnl8segJ.js} +1 -1
  20. package/dist/assets/{SubordinateAssignmentModal-DflOgTr1.js → SubordinateAssignmentModal-CBIMPvXj.js} +1 -1
  21. package/dist/assets/{TriggerManagerPanel-wzVna9Th.js → TriggerManagerPanel-Bt5jFa9e.js} +1 -1
  22. package/dist/assets/{WorkflowEditorPanel-Cv1Bb_0g.js → WorkflowEditorPanel-Dr7w30kC.js} +1 -1
  23. package/dist/assets/{area-logos-6NdoVnoc.js → area-logos-CbqAEc5x.js} +1 -1
  24. package/dist/assets/{index-sLCEQwTe.js → index-B_EnZVF3.js} +3 -3
  25. package/dist/assets/{index-Tn-mXnAH.js → index-BajNxeMu.js} +1 -1
  26. package/dist/assets/{index-1_kNHqvQ.js → index-C7vxRa_S.js} +1 -1
  27. package/dist/assets/{index-Bsf5hZbp.js → index-Cah-uKuh.js} +1 -1
  28. package/dist/assets/{index-SaaO8V1I.js → index-Cs3R3S9l.js} +2 -2
  29. package/dist/assets/{index-CoGlYrB7.js → index-D-b-ReZX.js} +1 -1
  30. package/dist/assets/{index-D8cfBFRC.js → index-D5ncPXgD.js} +1 -1
  31. package/dist/assets/{index-DZcn4bXn.js → index-DAjLIFPG.js} +1 -1
  32. package/dist/assets/{index-CqwyKXtm.js → index-DHPrZrMN.js} +1 -1
  33. package/dist/assets/main-BUUWICEe.css +1 -0
  34. package/dist/assets/main-D5m1NJ8q.js +233 -0
  35. package/dist/assets/{web-SdWeOa6A.js → web-C--hoJ1w.js} +1 -1
  36. package/dist/assets/{web-CNVP9dOO.js → web-CbFuYEMc.js} +1 -1
  37. package/dist/assets/{web-BzTFbp-p.js → web-Dh2cBvOj.js} +1 -1
  38. package/dist/index.html +2 -2
  39. package/dist/src/packages/server/routes/perf.js +3 -0
  40. package/dist/src/packages/server/services/agent-service.js +28 -3
  41. package/dist/src/packages/server/services/building-service.js +34 -4
  42. package/dist/src/packages/server/services/git-watch-service.js +270 -0
  43. package/dist/src/packages/server/websocket/handler.js +65 -34
  44. package/dist/src/packages/server/websocket/handlers/git-handler.js +11 -0
  45. package/dist/src/packages/server/websocket/listeners/index.js +7 -1
  46. package/package.json +1 -1
  47. package/dist/assets/main-BPim_cvM.css +0 -1
  48. package/dist/assets/main-DffU4pm2.js +0 -230
@@ -1 +1 @@
1
- import{cO as t}from"./main-DffU4pm2.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
1
+ import{cO as t}from"./main-D5m1NJ8q.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
@@ -1 +1 @@
1
- import{cO as s}from"./main-DffU4pm2.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{cO as s}from"./main-D5m1NJ8q.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
@@ -1 +1 @@
1
- import{cO as a}from"./main-DffU4pm2.js";import{ImpactStyle as i,NotificationType as r}from"./index-SaaO8V1I.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{cO as a}from"./main-D5m1NJ8q.js";import{ImpactStyle as i,NotificationType as r}from"./index-Cs3R3S9l.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
package/dist/index.html CHANGED
@@ -22,10 +22,10 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-DffU4pm2.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-D5m1NJ8q.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
- <link rel="stylesheet" crossorigin href="/assets/main-BPim_cvM.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-BUUWICEe.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -23,6 +23,9 @@ export function incrementWsReceived() {
23
23
  export function setWsClientsCount(count) {
24
24
  wsClientsCount = count;
25
25
  }
26
+ export function getWsClientsCount() {
27
+ return wsClientsCount;
28
+ }
26
29
  const recentRequests = [];
27
30
  const MAX_RECENT_REQUESTS = 100;
28
31
  export function recordRequestTiming(method, path, duration) {
@@ -29,6 +29,15 @@ function getDefaultContextLimit(provider, model) {
29
29
  // In-memory agent storage
30
30
  const agents = new Map();
31
31
  const listeners = new Set();
32
+ // Fields that change constantly while an agent works (several times per tool
33
+ // call). When an update changes nothing outside this set, the 'updated' event
34
+ // is emitted as quiet.
35
+ const QUIET_UPDATE_FIELDS = new Set([
36
+ 'currentTool',
37
+ 'contextUsed',
38
+ 'contextLimit',
39
+ 'contextStats',
40
+ ]);
32
41
  const pendingPropertyUpdates = new Map();
33
42
  export function sanitizeModelForProvider(provider, model) {
34
43
  if (provider !== 'claude')
@@ -257,8 +266,8 @@ export function subscribe(listener) {
257
266
  listeners.add(listener);
258
267
  return () => listeners.delete(listener);
259
268
  }
260
- function emit(event, data) {
261
- listeners.forEach((listener) => listener(event, data));
269
+ function emit(event, data, meta) {
270
+ listeners.forEach((listener) => listener(event, data, meta));
262
271
  }
263
272
  // ============================================================================
264
273
  // Agent CRUD
@@ -474,6 +483,16 @@ export function updateAgent(id, updates, updateActivity = true) {
474
483
  normalizedUpdates.trackingStatusDetail = undefined;
475
484
  normalizedUpdates.trackingStatusTimestamp = undefined;
476
485
  }
486
+ // Determine which fields actually change value (shallow — replaced objects
487
+ // like contextStats/position always count as changed). Drives the `quiet`
488
+ // flag on the 'updated' event below.
489
+ const changedKeys = [];
490
+ for (const key of Object.keys(normalizedUpdates)) {
491
+ const before = agent[key];
492
+ const after = normalizedUpdates[key];
493
+ if (before !== after)
494
+ changedKeys.push(key);
495
+ }
477
496
  // Only update lastActivity for real activity (not position changes, etc.)
478
497
  if (updateActivity) {
479
498
  Object.assign(agent, normalizedUpdates, { lastActivity: Date.now() });
@@ -491,7 +510,13 @@ export function updateAgent(id, updates, updateActivity = true) {
491
510
  if (sessionIdBefore !== agent.sessionId) {
492
511
  log.warn(`🔑 [SESSION CHANGE] Agent ${agent.name} (${id}): sessionId changed from "${sessionIdBefore}" to "${agent.sessionId}". Updates had sessionId: ${hasSessionIdInUpdates}, updates keys: ${Object.keys(normalizedUpdates).join(', ')}`);
493
512
  }
494
- emit('updated', agent);
513
+ // Quiet when nothing changed, or everything that changed is a high-churn
514
+ // metric field. During streaming this is ~3 updates per tool call
515
+ // (status:'working' no-op, currentTool set/clear, context tokens) — without
516
+ // the flag each one broadcast the FULL agent (contextStats, todos, config)
517
+ // to every connected client.
518
+ const quiet = changedKeys.every((key) => QUIET_UPDATE_FIELDS.has(key));
519
+ emit('updated', agent, { quiet });
495
520
  if (statusBefore !== agent.status) {
496
521
  void publishNotification('statusChanges', `${agentNameBefore}: status changed`, `${statusBefore ?? 'unknown'} → ${agent.status ?? 'unknown'}`).catch(() => { });
497
522
  }
@@ -6,6 +6,7 @@
6
6
  import { exec } from 'child_process';
7
7
  import { promisify } from 'util';
8
8
  import { loadBuildings, saveBuildings } from '../data/index.js';
9
+ import { getWsClientsCount } from '../routes/perf.js';
9
10
  import { createLogger, generateId } from '../utils/index.js';
10
11
  import * as pm2Service from './pm2-service.js';
11
12
  import * as dockerService from './docker-service.js';
@@ -573,20 +574,49 @@ export function getSubordinateBuildings(buildingId) {
573
574
  // PM2 Status Polling
574
575
  // ============================================================================
575
576
  let pollInterval = null;
577
+ let pollBroadcast = null;
578
+ let pollInFlight = false;
576
579
  /**
577
580
  * Start polling PM2 for status updates
578
581
  * Syncs PM2 process status with building status
582
+ *
583
+ * Each cycle spawns `pm2 jlist` (a full Node process), so cycles are skipped
584
+ * while no WebSocket client is connected — nobody is looking at the status
585
+ * badges, and a fresh poll runs on the next client connect
586
+ * (triggerPM2StatusPoll from the WS connection handler).
579
587
  */
580
- export function startPM2StatusPolling(broadcast, intervalMs = 10000) {
588
+ export function startPM2StatusPolling(broadcast, intervalMs = 30000) {
581
589
  if (pollInterval) {
582
590
  log.log('PM2 status polling already running');
583
591
  return;
584
592
  }
585
- log.log(`Starting PM2 status polling (interval: ${intervalMs}ms)`);
593
+ pollBroadcast = broadcast;
594
+ log.log(`Starting PM2 status polling (interval: ${intervalMs}ms, paused while no clients)`);
586
595
  // Initial poll
587
- pollPM2Status(broadcast);
596
+ void guardedPoll(broadcast);
588
597
  // Set up interval
589
- pollInterval = setInterval(() => pollPM2Status(broadcast), intervalMs);
598
+ pollInterval = setInterval(() => {
599
+ if (getWsClientsCount() === 0)
600
+ return;
601
+ void guardedPoll(broadcast);
602
+ }, intervalMs);
603
+ }
604
+ /** Run one poll immediately (used when a client connects mid-idle). */
605
+ export function triggerPM2StatusPoll() {
606
+ if (pollBroadcast)
607
+ void guardedPoll(pollBroadcast);
608
+ }
609
+ // Prevents overlapping polls if `pm2 jlist` is slower than the interval.
610
+ async function guardedPoll(broadcast) {
611
+ if (pollInFlight)
612
+ return;
613
+ pollInFlight = true;
614
+ try {
615
+ await pollPM2Status(broadcast);
616
+ }
617
+ finally {
618
+ pollInFlight = false;
619
+ }
590
620
  }
591
621
  /**
592
622
  * Stop PM2 status polling
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Git Watch Service — server-side git status polling pushed over WebSocket.
3
+ *
4
+ * Replaces per-client HTTP polling of GET /api/files/git-status and
5
+ * /api/files/git-branch. Each client declares the directories it cares about
6
+ * via the `git_watch` message; this service polls the UNION of all watched
7
+ * directories once (shared across every connected client), and pushes a
8
+ * `git_status_update` only when a directory's status actually changed.
9
+ *
10
+ * Net effect vs polling: one async git run per directory per cycle regardless
11
+ * of client count, zero network traffic while nothing changes, and no
12
+ * event-loop blocking (the HTTP routes use execSync; this service uses
13
+ * execFile). Polling stops entirely when no client watches anything —
14
+ * e.g. when the mobile app parks its socket in the background.
15
+ */
16
+ import { execFile } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { logger } from '../utils/index.js';
21
+ const execFileAsync = promisify(execFile);
22
+ const log = logger.files;
23
+ const POLL_INTERVAL_MS = 10_000;
24
+ const GIT_TIMEOUT_MS = 8_000;
25
+ // Safety cap per socket — far above any real area/building configuration.
26
+ const MAX_WATCHED_PATHS = 200;
27
+ const subscriptions = new Map();
28
+ const cache = new Map();
29
+ let pollTimer = null;
30
+ let pollInFlight = false;
31
+ async function git(args, cwd) {
32
+ const { stdout } = await execFileAsync('git', args, {
33
+ cwd,
34
+ timeout: GIT_TIMEOUT_MS,
35
+ maxBuffer: 10 * 1024 * 1024,
36
+ encoding: 'utf-8',
37
+ });
38
+ return stdout;
39
+ }
40
+ function toPosixSeparators(p) {
41
+ return p.split(path.sep).join('/');
42
+ }
43
+ /** Parse `git status --porcelain -uall` output into absolute-path file entries. */
44
+ function parsePorcelain(gitRoot, output) {
45
+ const files = [];
46
+ const lines = output.replace(/\n$/, '').split('\n').filter(Boolean);
47
+ for (const line of lines) {
48
+ // Porcelain v1: "XY PATH" or "XY ORIG -> NEW" for renames. Paths are
49
+ // relative to the git root.
50
+ const indexStatus = line[0];
51
+ const workTreeStatus = line[1];
52
+ const filePart = line.substring(3);
53
+ let filePath;
54
+ let oldPath;
55
+ if (filePart.includes(' -> ')) {
56
+ const [old, newPath] = filePart.split(' -> ');
57
+ filePath = toPosixSeparators(path.join(gitRoot, newPath));
58
+ oldPath = toPosixSeparators(path.join(gitRoot, old));
59
+ }
60
+ else {
61
+ filePath = toPosixSeparators(path.join(gitRoot, filePart));
62
+ }
63
+ let status;
64
+ if ((indexStatus === 'U' || workTreeStatus === 'U') ||
65
+ (indexStatus === 'D' && workTreeStatus === 'D') ||
66
+ (indexStatus === 'A' && workTreeStatus === 'A')) {
67
+ status = 'conflict';
68
+ }
69
+ else if (indexStatus === 'A' && workTreeStatus === 'D') {
70
+ // Orphaned staged-add (file deleted from disk) — the git-status HTTP
71
+ // route auto-unstages these; the read-only watcher just skips them.
72
+ continue;
73
+ }
74
+ else if (indexStatus === '?' || workTreeStatus === '?') {
75
+ status = 'untracked';
76
+ }
77
+ else if (indexStatus === 'A' || workTreeStatus === 'A') {
78
+ status = 'added';
79
+ }
80
+ else if (indexStatus === 'D' || workTreeStatus === 'D') {
81
+ status = 'deleted';
82
+ }
83
+ else if (indexStatus === 'R' || workTreeStatus === 'R') {
84
+ status = 'renamed';
85
+ }
86
+ else {
87
+ status = 'modified';
88
+ }
89
+ files.push({ path: filePath, name: path.basename(filePath), status, oldPath });
90
+ }
91
+ return files;
92
+ }
93
+ async function computeStatus(dirPath) {
94
+ const empty = {
95
+ path: dirPath,
96
+ isGitRepo: false,
97
+ branch: null,
98
+ ahead: 0,
99
+ behind: 0,
100
+ mergeInProgress: false,
101
+ files: [],
102
+ };
103
+ if (!path.isAbsolute(dirPath) || !fs.existsSync(dirPath))
104
+ return empty;
105
+ let gitRoot;
106
+ try {
107
+ gitRoot = (await git(['rev-parse', '--show-toplevel'], dirPath)).trim();
108
+ }
109
+ catch {
110
+ return empty;
111
+ }
112
+ let files = [];
113
+ try {
114
+ files = parsePorcelain(gitRoot, await git(['status', '--porcelain', '-uall'], dirPath));
115
+ }
116
+ catch (err) {
117
+ log.error(`[GitWatch] git status failed for ${dirPath}:`, err);
118
+ }
119
+ let branch = null;
120
+ try {
121
+ branch = (await git(['branch', '--show-current'], gitRoot)).trim() || 'HEAD';
122
+ }
123
+ catch {
124
+ branch = 'HEAD';
125
+ }
126
+ let ahead = 0;
127
+ let behind = 0;
128
+ if (branch && branch !== 'HEAD') {
129
+ try {
130
+ const revList = (await git(['rev-list', '--left-right', '--count', `${branch}...@{upstream}`], gitRoot)).trim();
131
+ const parts = revList.split(/\s+/);
132
+ if (parts.length === 2) {
133
+ ahead = parseInt(parts[0], 10) || 0;
134
+ behind = parseInt(parts[1], 10) || 0;
135
+ }
136
+ }
137
+ catch {
138
+ // No upstream configured — leave at 0
139
+ }
140
+ }
141
+ return {
142
+ path: dirPath,
143
+ isGitRepo: true,
144
+ branch,
145
+ ahead,
146
+ behind,
147
+ mergeInProgress: fs.existsSync(path.join(gitRoot, '.git', 'MERGE_HEAD')),
148
+ files,
149
+ };
150
+ }
151
+ function sendStatus(ws, payload) {
152
+ if (ws.readyState !== ws.OPEN)
153
+ return;
154
+ try {
155
+ ws.send(JSON.stringify({ type: 'git_status_update', payload }));
156
+ }
157
+ catch (err) {
158
+ log.error('[GitWatch] Failed to send git_status_update:', err);
159
+ }
160
+ }
161
+ function watchedUnion() {
162
+ const union = new Set();
163
+ for (const paths of subscriptions.values()) {
164
+ for (const p of paths)
165
+ union.add(p);
166
+ }
167
+ return union;
168
+ }
169
+ function pushToSubscribers(payload) {
170
+ for (const [ws, paths] of subscriptions) {
171
+ if (paths.has(payload.path))
172
+ sendStatus(ws, payload);
173
+ }
174
+ }
175
+ /** Compute a dir, update the cache, and push to subscribers if it changed. */
176
+ async function refreshOne(dirPath, forcePushTo) {
177
+ const payload = await computeStatus(dirPath);
178
+ const fingerprint = JSON.stringify(payload);
179
+ const prev = cache.get(dirPath);
180
+ cache.set(dirPath, { fingerprint, payload });
181
+ if (!prev || prev.fingerprint !== fingerprint) {
182
+ pushToSubscribers(payload);
183
+ }
184
+ else if (forcePushTo) {
185
+ // Unchanged, but an explicit refresh/subscribe asked for it.
186
+ sendStatus(forcePushTo, payload);
187
+ }
188
+ }
189
+ async function pollOnce() {
190
+ if (pollInFlight)
191
+ return;
192
+ pollInFlight = true;
193
+ try {
194
+ const union = watchedUnion();
195
+ // Drop cache entries nobody watches anymore.
196
+ for (const key of cache.keys()) {
197
+ if (!union.has(key))
198
+ cache.delete(key);
199
+ }
200
+ // Sequential on purpose: keeps the git subprocess load flat no matter how
201
+ // many directories are watched. Local git status is fast (~tens of ms).
202
+ for (const dirPath of union) {
203
+ if (!watchedUnion().has(dirPath))
204
+ continue; // unsubscribed mid-cycle
205
+ await refreshOne(dirPath);
206
+ }
207
+ }
208
+ catch (err) {
209
+ log.error('[GitWatch] Poll cycle failed:', err);
210
+ }
211
+ finally {
212
+ pollInFlight = false;
213
+ }
214
+ }
215
+ function ensurePolling() {
216
+ if (pollTimer || subscriptions.size === 0)
217
+ return;
218
+ pollTimer = setInterval(() => void pollOnce(), POLL_INTERVAL_MS);
219
+ }
220
+ function stopPollingIfIdle() {
221
+ if (subscriptions.size === 0 && pollTimer) {
222
+ clearInterval(pollTimer);
223
+ pollTimer = null;
224
+ cache.clear();
225
+ }
226
+ }
227
+ export const gitWatchService = {
228
+ /**
229
+ * Replace a socket's watched directory set. Immediately sends the current
230
+ * status for every requested path (cached, or freshly computed) so the
231
+ * client renders without waiting for the next poll cycle.
232
+ */
233
+ setWatchList(ws, paths) {
234
+ const cleaned = [...new Set((paths || [])
235
+ .filter((p) => typeof p === 'string' && path.isAbsolute(p))
236
+ .slice(0, MAX_WATCHED_PATHS))];
237
+ if (cleaned.length === 0) {
238
+ subscriptions.delete(ws);
239
+ stopPollingIfIdle();
240
+ return;
241
+ }
242
+ subscriptions.set(ws, new Set(cleaned));
243
+ ensurePolling();
244
+ void (async () => {
245
+ for (const dirPath of cleaned) {
246
+ const cached = cache.get(dirPath);
247
+ if (cached) {
248
+ sendStatus(ws, cached.payload);
249
+ }
250
+ else {
251
+ await refreshOne(dirPath, ws);
252
+ }
253
+ }
254
+ })();
255
+ },
256
+ /** Recompute the given (watched) paths now and push results. */
257
+ async refreshPaths(paths, requester) {
258
+ const union = watchedUnion();
259
+ for (const dirPath of paths || []) {
260
+ if (typeof dirPath !== 'string' || !union.has(dirPath))
261
+ continue;
262
+ await refreshOne(dirPath, requester);
263
+ }
264
+ },
265
+ /** Forget a socket (call on WS close). */
266
+ removeSocket(ws) {
267
+ subscriptions.delete(ws);
268
+ stopPollingIfIdle();
269
+ },
270
+ };
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { hostname } from 'node:os';
6
6
  import { WebSocketServer, WebSocket } from 'ws';
7
- import { agentService, bossMessageService, customClassService, permissionService, runtimeService, skillService, triggerService, workflowService, } from '../services/index.js';
7
+ import { agentService, bossMessageService, buildingService, customClassService, permissionService, runtimeService, skillService, triggerService, workflowService, } from '../services/index.js';
8
8
  import { loadAreas, loadBuildings } from '../data/index.js';
9
9
  import { logger } from '../utils/index.js';
10
10
  import { setNotificationBroadcast, setExecBroadcast, setTestsBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast } from '../routes/index.js';
@@ -16,6 +16,8 @@ import { handleSpawnBossAgent, handleAssignSubordinates, handleRemoveSubordinate
16
16
  import { handleCreateCustomAgentClass, handleUpdateCustomAgentClass, handleDeleteCustomAgentClass, } from './handlers/custom-class-handler.js';
17
17
  import { handleBuildingCommand, handlePM2LogsStart, handlePM2LogsStop, handleDockerLogsStart, handleDockerLogsStop, handleDockerListContainers, handleBossBuildingCommand, handleAssignBuildings, handleBossBuildingLogsStart, handleBossBuildingLogsStop, } from './handlers/building-handler.js';
18
18
  import { handleSendCommand } from './handlers/command-handler.js';
19
+ import { handleGitWatch, handleGitRefresh } from './handlers/git-handler.js';
20
+ import { gitWatchService } from '../services/git-watch-service.js';
19
21
  import { handleCreateSecret, handleUpdateSecret, handleDeleteSecret, } from './handlers/secrets-handler.js';
20
22
  import { secretsService } from '../services/secrets-service.js';
21
23
  import { handleTestDatabaseConnection, handleTestDatabaseConnectionTransient, handleListDatabases, handleListTables, handleGetTableSchema, handleExecuteQuery, handleRequestQueryHistory, handleToggleQueryFavorite, handleDeleteQueryHistory, handleClearQueryHistory, } from './handlers/database-handler.js';
@@ -65,13 +67,21 @@ function messageReplacer(_key, value) {
65
67
  function serializeMessage(message) {
66
68
  return JSON.stringify(message, messageReplacer);
67
69
  }
70
+ // Notification-only clients (connected with /ws?mode=notify, e.g. the Android
71
+ // foreground service) subscribe exclusively to these types. Everything else —
72
+ // output streams, activity, state syncs — is skipped so a backgrounded phone's
73
+ // radio/CPU stay idle unless a real notification arrives.
74
+ const NOTIFY_ONLY_TYPES = new Set(['agent_notification']);
75
+ function wantsMessage(client, messageType) {
76
+ return !client.__tcNotifyOnly || NOTIFY_ONLY_TYPES.has(messageType);
77
+ }
68
78
  export function broadcast(message) {
69
79
  try {
70
80
  const data = serializeMessage(message);
71
81
  let sentCount = 0;
72
82
  let errorCount = 0;
73
83
  for (const client of clients) {
74
- if (client.readyState === WebSocket.OPEN) {
84
+ if (client.readyState === WebSocket.OPEN && wantsMessage(client, message.type)) {
75
85
  try {
76
86
  client.send(data);
77
87
  sentCount++;
@@ -94,7 +104,7 @@ export function broadcast(message) {
94
104
  function broadcastToOthers(sender, message) {
95
105
  const data = serializeMessage(message);
96
106
  for (const client of clients) {
97
- if (client !== sender && client.readyState === WebSocket.OPEN) {
107
+ if (client !== sender && client.readyState === WebSocket.OPEN && wantsMessage(client, message.type)) {
98
108
  client.send(data);
99
109
  }
100
110
  }
@@ -155,6 +165,8 @@ const messageHandlers = {
155
165
  building_command: handleBuildingCommand,
156
166
  pm2_logs_start: handlePM2LogsStart,
157
167
  pm2_logs_stop: handlePM2LogsStop,
168
+ git_watch: handleGitWatch,
169
+ git_refresh: handleGitRefresh,
158
170
  docker_logs_start: handleDockerLogsStart,
159
171
  docker_logs_stop: handleDockerLogsStop,
160
172
  docker_list_containers: (ctx) => handleDockerListContainers(ctx),
@@ -268,32 +280,45 @@ export function init(server) {
268
280
  catch {
269
281
  /* best-effort — client info is diagnostic only */
270
282
  }
271
- log.log(`Client connected (total: ${clients.size})`);
272
- // Send initial state immediately status sync runs in background
273
- const customClasses = customClassService.getAllCustomClasses();
274
- ws.send(JSON.stringify({ type: 'custom_agent_classes_update', payload: customClasses }));
275
- const agents = agentService.getAllAgents();
276
- ws.send(JSON.stringify({ type: 'agents_update', payload: agents }));
277
- const areas = loadAreas();
278
- ws.send(JSON.stringify({ type: 'areas_update', payload: areas }));
279
- const buildings = loadBuildings();
280
- ws.send(JSON.stringify({ type: 'buildings_update', payload: buildings }));
281
- const skills = skillService.getAllSkills();
282
- ws.send(JSON.stringify({ type: 'skills_update', payload: skills }));
283
- const secrets = secretsService.getAllSecrets();
284
- ws.send(JSON.stringify({ type: 'secrets_update', payload: secrets }));
285
- const triggers = triggerService.getAllTriggers();
286
- ws.send(JSON.stringify({ type: 'triggers_update', payload: triggers }));
287
- const workflowDefs = workflowService.listDefinitions();
288
- ws.send(JSON.stringify({ type: 'workflow_definitions_update', payload: workflowDefs }));
289
- const pendingPermissions = permissionService.getPendingRequests();
290
- if (pendingPermissions.length > 0) {
291
- log.log(`Sending ${pendingPermissions.length} pending permission requests`);
292
- for (const request of pendingPermissions) {
293
- ws.send(JSON.stringify({
294
- type: 'permission_request',
295
- payload: request,
296
- }));
283
+ // Notification-only subscribers (the Android foreground service) get no
284
+ // broadcast firehose and no initial state just agent_notification.
285
+ const notifyOnly = (request.url || '').includes('mode=notify');
286
+ if (notifyOnly) {
287
+ ws.__tcNotifyOnly = true;
288
+ }
289
+ log.log(`${notifyOnly ? 'Notification-only client' : 'Client'} connected (total: ${clients.size})`);
290
+ if (!notifyOnly) {
291
+ // Send initial state immediately – status sync runs in background
292
+ // server_time goes FIRST: clients use it to align their clock with the
293
+ // server before any output arrives, so optimistic client-stamped items
294
+ // sort correctly against server-stamped outputs even on devices with a
295
+ // skewed clock (mobile).
296
+ ws.send(JSON.stringify({ type: 'server_time', payload: { timestamp: Date.now() } }));
297
+ const customClasses = customClassService.getAllCustomClasses();
298
+ ws.send(JSON.stringify({ type: 'custom_agent_classes_update', payload: customClasses }));
299
+ const agents = agentService.getAllAgents();
300
+ ws.send(JSON.stringify({ type: 'agents_update', payload: agents }));
301
+ const areas = loadAreas();
302
+ ws.send(JSON.stringify({ type: 'areas_update', payload: areas }));
303
+ const buildings = loadBuildings();
304
+ ws.send(JSON.stringify({ type: 'buildings_update', payload: buildings }));
305
+ const skills = skillService.getAllSkills();
306
+ ws.send(JSON.stringify({ type: 'skills_update', payload: skills }));
307
+ const secrets = secretsService.getAllSecrets();
308
+ ws.send(JSON.stringify({ type: 'secrets_update', payload: secrets }));
309
+ const triggers = triggerService.getAllTriggers();
310
+ ws.send(JSON.stringify({ type: 'triggers_update', payload: triggers }));
311
+ const workflowDefs = workflowService.listDefinitions();
312
+ ws.send(JSON.stringify({ type: 'workflow_definitions_update', payload: workflowDefs }));
313
+ const pendingPermissions = permissionService.getPendingRequests();
314
+ if (pendingPermissions.length > 0) {
315
+ log.log(`Sending ${pendingPermissions.length} pending permission requests`);
316
+ for (const request of pendingPermissions) {
317
+ ws.send(JSON.stringify({
318
+ type: 'permission_request',
319
+ payload: request,
320
+ }));
321
+ }
297
322
  }
298
323
  }
299
324
  ws.on('message', (data) => {
@@ -309,14 +334,20 @@ export function init(server) {
309
334
  ws.on('close', () => {
310
335
  clients.delete(ws);
311
336
  setWsClientsCount(clients.size);
337
+ gitWatchService.removeSocket(ws);
312
338
  log.log(`Client disconnected (remaining: ${clients.size})`);
313
339
  });
314
340
  // Background sync – refreshes agent status after initial data is sent
315
- runtimeService.syncAllAgentStatus().then(() => {
316
- if (ws.readyState === WebSocket.OPEN) {
317
- ws.send(JSON.stringify({ type: 'agents_update', payload: agentService.getAllAgents() }));
318
- }
319
- }).catch(() => { });
341
+ if (!notifyOnly) {
342
+ runtimeService.syncAllAgentStatus().then(() => {
343
+ if (ws.readyState === WebSocket.OPEN) {
344
+ ws.send(JSON.stringify({ type: 'agents_update', payload: agentService.getAllAgents() }));
345
+ }
346
+ }).catch(() => { });
347
+ // PM2 polling pauses while no clients are connected — run one poll now
348
+ // so building status badges are fresh for this client.
349
+ buildingService.triggerPM2StatusPoll();
350
+ }
320
351
  });
321
352
  setupServiceListeners({
322
353
  broadcast,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Git watch message handlers — subscribe a socket to server-side git status
3
+ * polling (git_watch) and force an immediate recompute (git_refresh).
4
+ */
5
+ import { gitWatchService } from '../../services/git-watch-service.js';
6
+ export function handleGitWatch(ctx, payload) {
7
+ gitWatchService.setWatchList(ctx.ws, payload?.paths || []);
8
+ }
9
+ export function handleGitRefresh(ctx, payload) {
10
+ void gitWatchService.refreshPaths(payload?.paths || [], ctx.ws);
11
+ }
@@ -5,11 +5,17 @@ import { setupAgentPromptListeners } from './agent-prompt-listeners.js';
5
5
  import { setupRuntimeListeners } from './runtime-listeners.js';
6
6
  import { setupSkillListeners } from './skill-listeners.js';
7
7
  export function setupServiceListeners(ctx) {
8
- agentService.subscribe((event, data) => {
8
+ agentService.subscribe((event, data, meta) => {
9
9
  switch (event) {
10
10
  case 'created':
11
11
  break;
12
12
  case 'updated':
13
+ // Quiet updates only changed high-churn metric fields (currentTool,
14
+ // context tokens) — clients already get those through the lightweight
15
+ // `context_update` and `event` messages, so skip the full-Agent
16
+ // broadcast (fires several times per tool call during streaming).
17
+ if (meta?.quiet)
18
+ break;
13
19
  ctx.broadcast({
14
20
  type: 'agent_updated',
15
21
  payload: data,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.131.0",
3
+ "version": "1.133.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",