@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.2
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/AdvancedSettings-BsZzYCUX.js +1 -0
- package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
- package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
- package/dist/assets/ConnectorsPane-sXLzWlmi.js +1 -0
- package/dist/assets/DocumentLibrary-C8MaQVoZ.js +1 -0
- package/dist/assets/DocumentLibrary-Cj9MJ9yW.css +1 -0
- package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
- package/dist/assets/FileBrowser-DAjSQXvM.js +4 -0
- package/dist/assets/MemoryDashboard-CANryTDV.js +1 -0
- package/dist/assets/MemoryDashboard-DgugVDad.css +1 -0
- package/dist/assets/MobileAccessModal-BukNLr4c.js +2 -0
- package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
- package/dist/assets/ScheduleManager-GwVTsKQO.css +1 -0
- package/dist/assets/ScheduleManager-o_9LgcpS.js +2 -0
- package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
- package/dist/assets/eye-B6FejHNG.js +1 -0
- package/dist/assets/index-B3UtHCtQ.css +1 -0
- package/dist/assets/index-CqsM0gYR.js +71 -0
- package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
- package/dist/assets/message-square-Dwm7y3C0.js +1 -0
- package/dist/index.html +6 -6
- package/main-safety-net.cjs +54 -7
- package/main.cjs +217 -58
- package/package.json +3 -1
- package/preload.cjs +24 -0
- package/services/agent-process-manager.cjs +101 -13
- package/services/auto-updater.cjs +26 -2
- package/services/backend-installer-progress-dialog.cjs +9 -9
- package/services/keep-awake.cjs +52 -0
- package/services/notification-service.cjs +62 -0
- package/services/port-manager.cjs +14 -70
- package/services/process-tree.cjs +305 -0
- package/services/quit-cleanup.cjs +77 -0
- package/services/quit-guard.cjs +242 -0
- package/services/tray-icons.cjs +174 -0
- package/services/tray-manager.cjs +582 -83
- package/services/tray-state.cjs +231 -0
- package/services/window-keys.cjs +43 -0
- package/assets/tray-icon.ico +0 -0
- package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
- package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
- package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
- package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
- package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
- package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
- package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
- package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
- package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
- package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
- package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
- package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
- package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
- package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
- package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
- package/dist/assets/index-CFVpPQal.css +0 -1
- package/dist/assets/index-DEPbenxC.js +0 -60
- package/dist/assets/message-square-Ccmbk96j.js +0 -1
- package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
- package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
|
@@ -26,6 +26,7 @@ const path = require("path");
|
|
|
26
26
|
const fs = require("fs");
|
|
27
27
|
const os = require("os");
|
|
28
28
|
const { EventEmitter } = require("events");
|
|
29
|
+
const { killProcessTree, listDescendants } = require("./process-tree.cjs");
|
|
29
30
|
|
|
30
31
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -38,6 +39,9 @@ const MANIFEST_FILENAME = "agent-manifest.json";
|
|
|
38
39
|
/** Graceful shutdown timeout before force kill (ms) */
|
|
39
40
|
const SHUTDOWN_TIMEOUT = 5000;
|
|
40
41
|
|
|
42
|
+
/** After the graceful shutdown, how long the tree gets between SIGTERM and SIGKILL (ms) */
|
|
43
|
+
const TREE_KILL_GRACE = 2000;
|
|
44
|
+
|
|
41
45
|
/** Health check interval (ms) — uses MCP "ping", not "initialize" */
|
|
42
46
|
const HEALTH_CHECK_INTERVAL = 30000;
|
|
43
47
|
|
|
@@ -82,6 +86,7 @@ class AgentProcessManager extends EventEmitter {
|
|
|
82
86
|
* handlers proxy to that backend's `/api/agents/*` routes (the real install
|
|
83
87
|
* runtime), so without a resolver those handlers fail loudly rather than
|
|
84
88
|
* duplicating download/verify/uv logic in the main process (issue #1721).
|
|
89
|
+
* killTree / listDescendants replace the process-tree helpers (tests).
|
|
85
90
|
*/
|
|
86
91
|
constructor(mainWindow, options = {}) {
|
|
87
92
|
super();
|
|
@@ -110,6 +115,12 @@ class AgentProcessManager extends EventEmitter {
|
|
|
110
115
|
*/
|
|
111
116
|
this.processes = {};
|
|
112
117
|
|
|
118
|
+
this._killTree = options.killTree || killProcessTree;
|
|
119
|
+
this._listDescendants = options.listDescendants || listDescendants;
|
|
120
|
+
|
|
121
|
+
/** Set by stopAll(): GAIA is quitting, so nothing may start another agent. */
|
|
122
|
+
this._shuttingDown = false;
|
|
123
|
+
|
|
113
124
|
/** Crash timestamps per agent for rate-limiting restart attempts */
|
|
114
125
|
this._crashTimes = {};
|
|
115
126
|
|
|
@@ -130,6 +141,11 @@ class AgentProcessManager extends EventEmitter {
|
|
|
130
141
|
* @returns {Promise<{ pid: number }>}
|
|
131
142
|
*/
|
|
132
143
|
async startAgent(agentId) {
|
|
144
|
+
if (this._shuttingDown) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Not starting agent "${agentId}": GAIA is quitting and has already stopped its agents.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
133
149
|
if (this.processes[agentId]) {
|
|
134
150
|
console.log(`[agent-mgr] Agent ${agentId} is already running (PID ${this.processes[agentId].process.pid})`);
|
|
135
151
|
return { pid: this.processes[agentId].process.pid };
|
|
@@ -166,10 +182,18 @@ class AgentProcessManager extends EventEmitter {
|
|
|
166
182
|
pendingRpc: {},
|
|
167
183
|
healthTimer: null,
|
|
168
184
|
stopping: false, // Set to true during intentional shutdown to suppress crash recovery
|
|
185
|
+
stopPromise: null, // The running stopAgent(), shared by duplicate calls
|
|
186
|
+
stdinError: null, // The error that broke stdin; nothing more can be written once set
|
|
169
187
|
};
|
|
170
188
|
|
|
171
189
|
this.processes[agentId] = entry;
|
|
172
190
|
|
|
191
|
+
// A write to a child that has died fails later, as an 'error' on the
|
|
192
|
+
// stream; without a listener that EPIPE crashes the whole app (#3498).
|
|
193
|
+
child.stdin.on("error", (err) => {
|
|
194
|
+
this._handleStdinError(agentId, child, err);
|
|
195
|
+
});
|
|
196
|
+
|
|
173
197
|
// ── stdout: JSON-RPC message stream ──
|
|
174
198
|
child.stdout.on("data", (data) => {
|
|
175
199
|
this._handleStdout(agentId, data);
|
|
@@ -207,6 +231,7 @@ class AgentProcessManager extends EventEmitter {
|
|
|
207
231
|
/**
|
|
208
232
|
* Stop an agent gracefully using JSON-RPC shutdown protocol.
|
|
209
233
|
* Cross-platform (works on Windows where SIGTERM = TerminateProcess).
|
|
234
|
+
* A second call while the first runs waits for that same stop.
|
|
210
235
|
* @param {string} agentId
|
|
211
236
|
* @returns {Promise<void>}
|
|
212
237
|
*/
|
|
@@ -217,23 +242,40 @@ class AgentProcessManager extends EventEmitter {
|
|
|
217
242
|
return;
|
|
218
243
|
}
|
|
219
244
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return;
|
|
245
|
+
if (entry.stopPromise) {
|
|
246
|
+
console.log(`[agent-mgr] Agent ${agentId} is already being stopped — waiting for that stop`);
|
|
247
|
+
return entry.stopPromise;
|
|
224
248
|
}
|
|
225
249
|
|
|
226
250
|
console.log(`[agent-mgr] Stopping agent ${agentId} (PID ${entry.process.pid})...`);
|
|
227
251
|
|
|
228
252
|
// Mark as intentionally stopping — suppresses crash recovery in _handleProcessExit
|
|
229
253
|
entry.stopping = true;
|
|
254
|
+
entry.stopPromise = this._stopAgent(agentId, entry);
|
|
255
|
+
return entry.stopPromise;
|
|
256
|
+
}
|
|
230
257
|
|
|
258
|
+
async _stopAgent(agentId, entry) {
|
|
231
259
|
// Clear health check timer
|
|
232
260
|
if (entry.healthTimer) {
|
|
233
261
|
clearInterval(entry.healthTimer);
|
|
234
262
|
entry.healthTimer = null;
|
|
235
263
|
}
|
|
236
264
|
|
|
265
|
+
// Read the tree while the agent is alive: once it exits, its children
|
|
266
|
+
// are re-parented on Linux and macOS and can no longer be found from it.
|
|
267
|
+
let knownDescendants = [];
|
|
268
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
269
|
+
try {
|
|
270
|
+
({ descendants: knownDescendants } = await this._listDescendants(entry.process.pid));
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.error(
|
|
273
|
+
`[agent-mgr] Could not list the processes agent ${agentId} started (${err.message}); ` +
|
|
274
|
+
"any it leaves behind will keep running."
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
237
279
|
// Step 1: Send JSON-RPC shutdown request via stdin
|
|
238
280
|
try {
|
|
239
281
|
this._sendJsonRpcRaw(agentId, "shutdown", {});
|
|
@@ -247,17 +289,17 @@ class AgentProcessManager extends EventEmitter {
|
|
|
247
289
|
// Step 2: Wait up to SHUTDOWN_TIMEOUT for clean exit
|
|
248
290
|
const exited = await this._waitForExit(agentId, SHUTDOWN_TIMEOUT);
|
|
249
291
|
|
|
250
|
-
// Step 3:
|
|
251
|
-
if (!exited
|
|
292
|
+
// Step 3: Stop the agent if it ignored the request, and anything it started either way.
|
|
293
|
+
if (!exited) {
|
|
252
294
|
console.warn(
|
|
253
295
|
`[agent-mgr] Agent ${agentId} did not exit within ${SHUTDOWN_TIMEOUT}ms, force killing...`
|
|
254
296
|
);
|
|
255
|
-
try {
|
|
256
|
-
entry.process.kill(); // SIGKILL on Unix, TerminateProcess on Windows
|
|
257
|
-
} catch {
|
|
258
|
-
// Already dead
|
|
259
|
-
}
|
|
260
297
|
}
|
|
298
|
+
await this._killTree(entry.process, {
|
|
299
|
+
knownDescendants,
|
|
300
|
+
graceMs: TREE_KILL_GRACE,
|
|
301
|
+
label: `agent ${agentId}`,
|
|
302
|
+
});
|
|
261
303
|
|
|
262
304
|
// Note: _handleProcessExit may have already cleaned up if the process exited.
|
|
263
305
|
// _cleanupProcess is idempotent, so calling it again is safe.
|
|
@@ -626,7 +668,13 @@ class AgentProcessManager extends EventEmitter {
|
|
|
626
668
|
|
|
627
669
|
entry.pendingRpc[id] = { resolve, reject, timer };
|
|
628
670
|
|
|
629
|
-
|
|
671
|
+
try {
|
|
672
|
+
this._sendJsonRpcRaw(agentId, method, params, id);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
delete entry.pendingRpc[id];
|
|
676
|
+
reject(err);
|
|
677
|
+
}
|
|
630
678
|
});
|
|
631
679
|
}
|
|
632
680
|
|
|
@@ -639,6 +687,7 @@ class AgentProcessManager extends EventEmitter {
|
|
|
639
687
|
const agentConfigs = this.config.agents || {};
|
|
640
688
|
|
|
641
689
|
for (const [agentId, agentCfg] of Object.entries(agentConfigs)) {
|
|
690
|
+
if (this._shuttingDown) return;
|
|
642
691
|
if (agentCfg.autoStart && !this.processes[agentId]) {
|
|
643
692
|
try {
|
|
644
693
|
await this.startAgent(agentId);
|
|
@@ -656,13 +705,16 @@ class AgentProcessManager extends EventEmitter {
|
|
|
656
705
|
}
|
|
657
706
|
|
|
658
707
|
/**
|
|
659
|
-
* Stop all running agents gracefully.
|
|
708
|
+
* Stop all running agents gracefully. For quitting only: afterwards no
|
|
709
|
+
* agent can be started, so a pending auto-start or crash restart can't
|
|
710
|
+
* spawn one the app will never stop.
|
|
660
711
|
*/
|
|
661
712
|
async stopAll() {
|
|
662
713
|
const agentIds = Object.keys(this.processes);
|
|
663
714
|
console.log(
|
|
664
715
|
`[agent-mgr] Stopping all agents: ${agentIds.join(", ") || "(none)"}`
|
|
665
716
|
);
|
|
717
|
+
this._shuttingDown = true;
|
|
666
718
|
await Promise.all(agentIds.map((id) => this.stopAgent(id)));
|
|
667
719
|
}
|
|
668
720
|
|
|
@@ -832,6 +884,7 @@ class AgentProcessManager extends EventEmitter {
|
|
|
832
884
|
);
|
|
833
885
|
|
|
834
886
|
setTimeout(async () => {
|
|
887
|
+
if (this._shuttingDown) return;
|
|
835
888
|
try {
|
|
836
889
|
await this.startAgent(agentId);
|
|
837
890
|
console.log(`[agent-mgr] Agent ${agentId} restarted after crash`);
|
|
@@ -858,6 +911,11 @@ class AgentProcessManager extends EventEmitter {
|
|
|
858
911
|
if (!entry || !entry.process.stdin || entry.process.stdin.destroyed) {
|
|
859
912
|
throw new Error(`Cannot write to stdin of agent "${agentId}"`);
|
|
860
913
|
}
|
|
914
|
+
if (entry.stdinError) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`Cannot write to stdin of agent "${agentId}": the pipe broke earlier (${entry.stdinError.message})`
|
|
917
|
+
);
|
|
918
|
+
}
|
|
861
919
|
|
|
862
920
|
const msg = {
|
|
863
921
|
jsonrpc: "2.0",
|
|
@@ -870,6 +928,36 @@ class AgentProcessManager extends EventEmitter {
|
|
|
870
928
|
entry.process.stdin.write(payload);
|
|
871
929
|
}
|
|
872
930
|
|
|
931
|
+
/**
|
|
932
|
+
* The agent's stdin failed — almost always EPIPE because the process is
|
|
933
|
+
* gone. Fail its pending calls now instead of letting them time out, and
|
|
934
|
+
* make sure the death is reported: a child that only closed its stdin is
|
|
935
|
+
* unusable, so it is stopped and its exit goes through the crash path.
|
|
936
|
+
*/
|
|
937
|
+
_handleStdinError(agentId, child, err) {
|
|
938
|
+
const entry = this.processes[agentId];
|
|
939
|
+
const current = entry && entry.process === child ? entry : null;
|
|
940
|
+
const exited = child.exitCode !== null || child.signalCode !== null;
|
|
941
|
+
console.error(
|
|
942
|
+
`[agent-mgr] stdin of agent ${agentId} (PID ${child.pid}) failed: ${err.code || "error"} ${err.message}` +
|
|
943
|
+
(exited ? " — the agent had already exited" : "")
|
|
944
|
+
);
|
|
945
|
+
if (!current) return;
|
|
946
|
+
current.stdinError = err;
|
|
947
|
+
|
|
948
|
+
for (const [rpcId, pending] of Object.entries(current.pendingRpc)) {
|
|
949
|
+
clearTimeout(pending.timer);
|
|
950
|
+
delete current.pendingRpc[rpcId];
|
|
951
|
+
pending.reject(new Error(`Agent "${agentId}" stopped reading its input (${err.code || err.message})`));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (exited || current.stopping) return;
|
|
955
|
+
console.error(`[agent-mgr] Agent ${agentId} can no longer receive requests — stopping it`);
|
|
956
|
+
this._killTree(child, { graceMs: TREE_KILL_GRACE, label: `agent ${agentId}` }).catch((killErr) => {
|
|
957
|
+
console.error(`[agent-mgr] Could not stop agent ${agentId} after its stdin broke: ${killErr.message}`);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
873
961
|
// ── Private: Health check ────────────────────────────────────────────
|
|
874
962
|
|
|
875
963
|
async _healthCheck(agentId) {
|
|
@@ -106,6 +106,8 @@ const state = {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
let mainWindowRef = null;
|
|
109
|
+
/** Asks before an update restart would cut off running work (set by init). */
|
|
110
|
+
let confirmRestartRef = null;
|
|
109
111
|
let checkInProgress = false;
|
|
110
112
|
let scheduledTimeout = null;
|
|
111
113
|
let initialCheckTimeout = null;
|
|
@@ -616,13 +618,25 @@ function wireAutoUpdaterEvents() {
|
|
|
616
618
|
}
|
|
617
619
|
);
|
|
618
620
|
if (choice && choice.response === 0) {
|
|
621
|
+
// quitAndInstall starts the installer at once, so ask about running
|
|
622
|
+
// work here; the quit that follows doesn't ask again.
|
|
623
|
+
if (!(await confirmRestartRef())) {
|
|
624
|
+
log("info", "Restart put off: work is still running — will install on next quit");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
619
627
|
log("info", "User chose to restart — calling quitAndInstall");
|
|
620
628
|
autoUpdaterRef.quitAndInstall(false, true);
|
|
621
629
|
} else {
|
|
622
630
|
log("info", "User deferred restart — will install on next quit");
|
|
623
631
|
}
|
|
624
632
|
} catch (err) {
|
|
625
|
-
|
|
633
|
+
const message = (err && err.message) || String(err);
|
|
634
|
+
log("error", "Failed to show restart dialog:", message);
|
|
635
|
+
electronApi.dialog.showErrorBox(
|
|
636
|
+
"GAIA update",
|
|
637
|
+
`GAIA couldn't finish asking about the update restart: ${message}\n\n` +
|
|
638
|
+
"The update installs the next time you quit GAIA."
|
|
639
|
+
);
|
|
626
640
|
}
|
|
627
641
|
});
|
|
628
642
|
|
|
@@ -689,9 +703,19 @@ function unregisterIpcHandlers() {
|
|
|
689
703
|
* continues, and this function itself never throws.
|
|
690
704
|
*
|
|
691
705
|
* @param {Electron.BrowserWindow | null} mainWindow
|
|
706
|
+
* @param {object} options
|
|
707
|
+
* @param {() => Promise<boolean>} options.confirmRestart - Whether "Restart now"
|
|
708
|
+
* may go ahead; asked before the installer starts. Required: without it an
|
|
709
|
+
* update restart would silently cut off a running answer.
|
|
692
710
|
*/
|
|
693
|
-
function init(mainWindow) {
|
|
711
|
+
function init(mainWindow, options = {}) {
|
|
712
|
+
if (typeof options.confirmRestart !== "function") {
|
|
713
|
+
throw new TypeError(
|
|
714
|
+
"auto-updater init needs options.confirmRestart, so Restart now can ask before it stops running work"
|
|
715
|
+
);
|
|
716
|
+
}
|
|
694
717
|
mainWindowRef = mainWindow || null;
|
|
718
|
+
confirmRestartRef = options.confirmRestart;
|
|
695
719
|
|
|
696
720
|
// Disabled path — short-circuit BEFORE touching any Electron APIs so
|
|
697
721
|
// this works in plain Node tests where require('electron') returns a
|
|
@@ -90,13 +90,13 @@ function buildProgressHtml({ logPath }) {
|
|
|
90
90
|
* src/styles/index.css, so the dark theme's role values are mirrored
|
|
91
91
|
* here. Keep them in step with that file; nothing below names a hex. */
|
|
92
92
|
:root {
|
|
93
|
-
--bg-primary: #
|
|
94
|
-
--bg-tertiary: #
|
|
95
|
-
--border: #
|
|
96
|
-
--text-primary: #
|
|
97
|
-
--text-secondary: #
|
|
98
|
-
--text-muted: #
|
|
99
|
-
--accent-
|
|
93
|
+
--bg-primary: #1A1917;
|
|
94
|
+
--bg-tertiary: #2C2B27;
|
|
95
|
+
--border: #4A4740;
|
|
96
|
+
--text-primary: #EFECE6;
|
|
97
|
+
--text-secondary: #B3ADA5;
|
|
98
|
+
--text-muted: #958F87;
|
|
99
|
+
--accent-solid: #D4B47E;
|
|
100
100
|
}
|
|
101
101
|
html, body {
|
|
102
102
|
margin: 0;
|
|
@@ -152,7 +152,7 @@ function buildProgressHtml({ logPath }) {
|
|
|
152
152
|
}
|
|
153
153
|
.fill {
|
|
154
154
|
height: 100%;
|
|
155
|
-
background: var(--accent-
|
|
155
|
+
background: var(--accent-solid);
|
|
156
156
|
border-radius: 9999px;
|
|
157
157
|
transition: width 300ms ease-out;
|
|
158
158
|
width: 0%;
|
|
@@ -268,7 +268,7 @@ function createProgressWindow() {
|
|
|
268
268
|
frame: false,
|
|
269
269
|
transparent: false,
|
|
270
270
|
show: false,
|
|
271
|
-
backgroundColor: "#
|
|
271
|
+
backgroundColor: "#1A1917", // --bg-primary, dark theme (see the :root block above)
|
|
272
272
|
title: "Installing GAIA",
|
|
273
273
|
webPreferences: {
|
|
274
274
|
nodeIntegration: false,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keep the PC from idle-sleeping while GAIA works (#5226).
|
|
6
|
+
*
|
|
7
|
+
* The renderer decides when (a turn is running, the setting is on, and the
|
|
8
|
+
* battery policy allows it) and calls `power:keep-awake`; this service owns
|
|
9
|
+
* the one powerSaveBlocker. The display may still turn off — only system
|
|
10
|
+
* sleep is held off.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { ipcMain, powerSaveBlocker } = require("electron");
|
|
14
|
+
|
|
15
|
+
class KeepAwake {
|
|
16
|
+
constructor() {
|
|
17
|
+
/** @type {number | null} */
|
|
18
|
+
this._blockerId = null;
|
|
19
|
+
ipcMain.handle("power:keep-awake", (_event, on) => this.set(on));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {boolean} on
|
|
24
|
+
* @returns {boolean} whether sleep is now being held off.
|
|
25
|
+
*/
|
|
26
|
+
set(on) {
|
|
27
|
+
if (typeof on !== "boolean") {
|
|
28
|
+
throw new TypeError(`power:keep-awake expects a boolean, got ${typeof on}`);
|
|
29
|
+
}
|
|
30
|
+
if (on && this._blockerId === null) {
|
|
31
|
+
this._blockerId = powerSaveBlocker.start("prevent-app-suspension");
|
|
32
|
+
console.log("[power] Holding off idle sleep while GAIA works");
|
|
33
|
+
} else if (!on && this._blockerId !== null) {
|
|
34
|
+
powerSaveBlocker.stop(this._blockerId);
|
|
35
|
+
this._blockerId = null;
|
|
36
|
+
console.log("[power] Idle sleep allowed again");
|
|
37
|
+
}
|
|
38
|
+
return this.active;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {boolean} */
|
|
42
|
+
get active() {
|
|
43
|
+
return this._blockerId !== null && powerSaveBlocker.isStarted(this._blockerId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
destroy() {
|
|
47
|
+
this.set(false);
|
|
48
|
+
ipcMain.removeHandler("power:keep-awake");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = KeepAwake;
|
|
@@ -314,6 +314,55 @@ class NotificationService extends EventEmitter {
|
|
|
314
314
|
this._sendToRenderer("tray:navigate", `notification:${notif.id}`);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// ── Chat alerts ──────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Show an OS banner for a chat event. Clicking it focuses the window and
|
|
321
|
+
* opens that chat. Silent: the renderer plays the user's chosen sound.
|
|
322
|
+
*
|
|
323
|
+
* @param {{title: string, body: string, sessionId: string}} payload
|
|
324
|
+
* @returns {boolean} false when the OS has no notification support.
|
|
325
|
+
*/
|
|
326
|
+
showChatBanner(payload) {
|
|
327
|
+
if (!payload || typeof payload.title !== "string" || typeof payload.sessionId !== "string") {
|
|
328
|
+
throw new TypeError("notification:show expects {title, body, sessionId}");
|
|
329
|
+
}
|
|
330
|
+
if (!Notification.isSupported()) {
|
|
331
|
+
console.warn("[notif] OS notifications not supported on this platform");
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const osNotif = new Notification({
|
|
335
|
+
title: payload.title,
|
|
336
|
+
body: typeof payload.body === "string" ? payload.body : "",
|
|
337
|
+
icon: path.join(__dirname, "..", "assets", "icon.png"),
|
|
338
|
+
silent: true,
|
|
339
|
+
});
|
|
340
|
+
osNotif.on("click", () => {
|
|
341
|
+
if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
|
|
342
|
+
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
|
|
343
|
+
this.mainWindow.show();
|
|
344
|
+
this.mainWindow.focus();
|
|
345
|
+
this._sendToRenderer("tray:navigate", `session:${payload.sessionId}`);
|
|
346
|
+
});
|
|
347
|
+
osNotif.show();
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Flash the taskbar button until the window is focused. A no-op while
|
|
353
|
+
* it already has focus.
|
|
354
|
+
* @returns {boolean} whether it flashed.
|
|
355
|
+
*/
|
|
356
|
+
flashWindow() {
|
|
357
|
+
const win = this.mainWindow;
|
|
358
|
+
if (!win || win.isDestroyed() || win.isFocused()) return false;
|
|
359
|
+
win.flashFrame(true);
|
|
360
|
+
win.once("focus", () => {
|
|
361
|
+
if (!win.isDestroyed()) win.flashFrame(false);
|
|
362
|
+
});
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
317
366
|
// ── Private: Tray badge ──────────────────────────────────────────────
|
|
318
367
|
|
|
319
368
|
_updateTrayBadge() {
|
|
@@ -423,6 +472,19 @@ class NotificationService extends EventEmitter {
|
|
|
423
472
|
this._respondToPermission(notifId, action, remember);
|
|
424
473
|
}
|
|
425
474
|
);
|
|
475
|
+
|
|
476
|
+
// Chat alerts (#5226): the renderer decides what fires per the user's
|
|
477
|
+
// settings; main owns the OS surfaces it can't reach.
|
|
478
|
+
ipcMain.handle("notification:show", (_event, payload) =>
|
|
479
|
+
this.showChatBanner(payload)
|
|
480
|
+
);
|
|
481
|
+
ipcMain.handle("notification:flash", () => this.flashWindow());
|
|
482
|
+
ipcMain.handle("notification:attention", (_event, count) => {
|
|
483
|
+
if (!this.trayManager) {
|
|
484
|
+
throw new Error("No tray to badge: the tray manager was not created");
|
|
485
|
+
}
|
|
486
|
+
this.trayManager.setAttentionCount(count);
|
|
487
|
+
});
|
|
426
488
|
}
|
|
427
489
|
|
|
428
490
|
// ── Private: Helpers ─────────────────────────────────────────────────
|
|
@@ -22,6 +22,7 @@ const fs = require("fs");
|
|
|
22
22
|
const os = require("os");
|
|
23
23
|
const path = require("path");
|
|
24
24
|
const { execFileSync } = require("child_process");
|
|
25
|
+
const { killProcessTree } = require("./process-tree.cjs");
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Resolve the GAIA home directory lazily so tests can point it elsewhere
|
|
@@ -54,86 +55,29 @@ function findFreePort() {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
|
-
* Terminate a spawned backend
|
|
58
|
+
* Terminate a spawned backend and everything it started: SIGTERM the tree,
|
|
59
|
+
* wait up to 3 s, then SIGKILL what is left. Without the tree walk, killing
|
|
60
|
+
* the uv `gaia.exe` launcher on Windows left its Python running.
|
|
58
61
|
* Accepts either a ChildProcess reference (preferred — lets us
|
|
59
62
|
* short-circuit via `exitCode` so PID reuse can never hit us) or a raw
|
|
60
|
-
* pid (
|
|
63
|
+
* pid (the orphan cleanup and tests). Safe to call with nullish.
|
|
61
64
|
*/
|
|
62
65
|
async function killBackend(procOrPid, logger) {
|
|
63
66
|
const log = (logger && logger.log) || console.log;
|
|
64
67
|
const logError = (logger && logger.error) || console.error;
|
|
65
|
-
|
|
66
68
|
if (!procOrPid) return;
|
|
67
|
-
|
|
68
|
-
// Distinguish ChildProcess vs raw pid. A ChildProcess has both .pid
|
|
69
|
-
// and .exitCode/.signalCode; typeof === "object" is enough.
|
|
70
|
-
const isChildProcess = typeof procOrPid === "object";
|
|
71
|
-
const proc = isChildProcess ? procOrPid : null;
|
|
72
|
-
const pid = isChildProcess ? procOrPid.pid : procOrPid;
|
|
73
|
-
|
|
74
|
-
if (!pid) return;
|
|
75
|
-
|
|
76
|
-
// If we have the ChildProcess handle, trust it — the exitCode is set
|
|
77
|
-
// synchronously by Node's child_process before any pid reuse could
|
|
78
|
-
// happen, so this closes the PID-reuse TOCTOU that a pid-only API has.
|
|
69
|
+
const proc = typeof procOrPid === "object" ? procOrPid : null;
|
|
79
70
|
if (proc && (proc.exitCode !== null || proc.signalCode !== null)) {
|
|
80
|
-
log(`[port-manager] child pid ${pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Is the process still alive? kill(pid, 0) throws ESRCH if not.
|
|
85
|
-
// Only relied on when the caller passed a raw pid (tests); callers
|
|
86
|
-
// with a ChildProcess were already short-circuited above.
|
|
87
|
-
const alive = (p) => {
|
|
88
|
-
try {
|
|
89
|
-
process.kill(p, 0);
|
|
90
|
-
return true;
|
|
91
|
-
} catch {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
if (!alive(pid)) {
|
|
97
|
-
log(`[port-manager] pid ${pid} already gone`);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
process.kill(pid, "SIGTERM");
|
|
103
|
-
log(`[port-manager] SIGTERM sent to pid ${pid}`);
|
|
104
|
-
} catch (err) {
|
|
105
|
-
logError(`[port-manager] SIGTERM failed for pid ${pid}: ${err.message}`);
|
|
71
|
+
log(`[port-manager] child pid ${proc.pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
|
|
106
72
|
return;
|
|
107
73
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
process.kill(pid, "SIGKILL");
|
|
120
|
-
log(`[port-manager] SIGKILL sent to pid ${pid} (did not exit on SIGTERM)`);
|
|
121
|
-
} catch (err) {
|
|
122
|
-
logError(`[port-manager] SIGKILL failed for pid ${pid}: ${err.message}`);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Poll until the kernel reaps the process so callers can safely rebind
|
|
127
|
-
// the port immediately after we return. 1 s cap at 50 ms intervals —
|
|
128
|
-
// SIGKILL is synchronous in the kernel but reaping the zombie can lag.
|
|
129
|
-
const killDeadline = Date.now() + 1000;
|
|
130
|
-
while (Date.now() < killDeadline) {
|
|
131
|
-
if (!alive(pid)) return;
|
|
132
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
133
|
-
}
|
|
134
|
-
logError(
|
|
135
|
-
`[port-manager] pid ${pid} still alive 1s after SIGKILL — orphan risk`,
|
|
136
|
-
);
|
|
74
|
+
await killProcessTree(procOrPid, {
|
|
75
|
+
graceMs: 3000,
|
|
76
|
+
forceWaitMs: 1000,
|
|
77
|
+
log,
|
|
78
|
+
logError,
|
|
79
|
+
label: "backend",
|
|
80
|
+
});
|
|
137
81
|
}
|
|
138
82
|
|
|
139
83
|
/**
|