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.
- package/dist/assets/{BossLogsModal-CWI0TeXV.js → BossLogsModal-DrdxKTb-.js} +1 -1
- package/dist/assets/{BossSpawnModal-CxiQJasU.js → BossSpawnModal-B2w4kFpi.js} +1 -1
- package/dist/assets/{ControlsModal-BrPnWWpS.js → ControlsModal-BWYKL25r.js} +1 -1
- package/dist/assets/{DockerLogsModal-D1oIAzfZ.js → DockerLogsModal-0fJudEU2.js} +1 -1
- package/dist/assets/{EmbeddedEditor-CHP_HStx.js → EmbeddedEditor-BDzAm2JK.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-DCn-EXjO.js → GmailOAuthSetup-CR0SPH0J.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-D26zofAR.js → GoogleOAuthSetup-BTDncXU3.js} +1 -1
- package/dist/assets/{IframeModal-CSN8ck7s.js → IframeModal-Csor52D5.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BU_xwMMT.js → IntegrationsPanel-qTRjzZ-I.js} +2 -2
- package/dist/assets/{LogViewerModal-Amod9PlS.js → LogViewerModal-BiPg_NL7.js} +1 -1
- package/dist/assets/{MonitoringModal-D-2zR9Oi.js → MonitoringModal-BKjwjSqZ.js} +1 -1
- package/dist/assets/{PM2LogsModal-B48puyEY.js → PM2LogsModal-BAEeg_iW.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-C4IVaTMA.js → RestoreArchivedAreaModal-Hm9Osmc4.js} +1 -1
- package/dist/assets/{Scene2DCanvas-BYdgkm1n.js → Scene2DCanvas-T-7D8dQ_.js} +1 -1
- package/dist/assets/{SceneManager-DVL0xQVk.js → SceneManager-C2rng0PA.js} +1 -1
- package/dist/assets/{SkillsPanel-BwCe6nFB.js → SkillsPanel-BD9RO30O.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-nSiRoxLD.js → SlackMultiInstanceSetup-CiK0r5fz.js} +1 -1
- package/dist/assets/{SpawnModal-DXvcL-cq.js → SpawnModal-Di4xD6Ue.js} +1 -1
- package/dist/assets/{StatisticsModal-Dgq-n8L6.js → StatisticsModal-Dnl8segJ.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DflOgTr1.js → SubordinateAssignmentModal-CBIMPvXj.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-wzVna9Th.js → TriggerManagerPanel-Bt5jFa9e.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-Cv1Bb_0g.js → WorkflowEditorPanel-Dr7w30kC.js} +1 -1
- package/dist/assets/{area-logos-6NdoVnoc.js → area-logos-CbqAEc5x.js} +1 -1
- package/dist/assets/{index-sLCEQwTe.js → index-B_EnZVF3.js} +3 -3
- package/dist/assets/{index-Tn-mXnAH.js → index-BajNxeMu.js} +1 -1
- package/dist/assets/{index-1_kNHqvQ.js → index-C7vxRa_S.js} +1 -1
- package/dist/assets/{index-Bsf5hZbp.js → index-Cah-uKuh.js} +1 -1
- package/dist/assets/{index-SaaO8V1I.js → index-Cs3R3S9l.js} +2 -2
- package/dist/assets/{index-CoGlYrB7.js → index-D-b-ReZX.js} +1 -1
- package/dist/assets/{index-D8cfBFRC.js → index-D5ncPXgD.js} +1 -1
- package/dist/assets/{index-DZcn4bXn.js → index-DAjLIFPG.js} +1 -1
- package/dist/assets/{index-CqwyKXtm.js → index-DHPrZrMN.js} +1 -1
- package/dist/assets/main-BUUWICEe.css +1 -0
- package/dist/assets/main-D5m1NJ8q.js +233 -0
- package/dist/assets/{web-SdWeOa6A.js → web-C--hoJ1w.js} +1 -1
- package/dist/assets/{web-CNVP9dOO.js → web-CbFuYEMc.js} +1 -1
- package/dist/assets/{web-BzTFbp-p.js → web-Dh2cBvOj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/routes/perf.js +3 -0
- package/dist/src/packages/server/services/agent-service.js +28 -3
- package/dist/src/packages/server/services/building-service.js +34 -4
- package/dist/src/packages/server/services/git-watch-service.js +270 -0
- package/dist/src/packages/server/websocket/handler.js +65 -34
- package/dist/src/packages/server/websocket/handlers/git-handler.js +11 -0
- package/dist/src/packages/server/websocket/listeners/index.js +7 -1
- package/package.json +1 -1
- package/dist/assets/main-BPim_cvM.css +0 -1
- package/dist/assets/main-DffU4pm2.js +0 -230
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cO as t}from"./main-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
593
|
+
pollBroadcast = broadcast;
|
|
594
|
+
log.log(`Starting PM2 status polling (interval: ${intervalMs}ms, paused while no clients)`);
|
|
586
595
|
// Initial poll
|
|
587
|
-
|
|
596
|
+
void guardedPoll(broadcast);
|
|
588
597
|
// Set up interval
|
|
589
|
-
pollInterval = setInterval(() =>
|
|
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
|
-
|
|
272
|
-
//
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
ws.
|
|
318
|
-
|
|
319
|
-
|
|
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,
|