loadtoagent 1.0.0 → 1.3.1

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 (86) hide show
  1. package/README.ko.md +7 -0
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +7 -0
  4. package/docs/PROVIDER-CONTRACTS.md +23 -1
  5. package/docs/UI-AUDIT-100.md +118 -0
  6. package/docs/UI-AUDIT-101-200.md +120 -0
  7. package/docs/UI-AUDIT-201-300.md +120 -0
  8. package/main.js +183 -37
  9. package/package.json +15 -4
  10. package/preload.js +11 -0
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -9
  13. package/renderer/app-dashboard.js +197 -58
  14. package/renderer/app-drawer-content.js +103 -86
  15. package/renderer/app-drawer-data.js +26 -13
  16. package/renderer/app-drawer.js +79 -55
  17. package/renderer/app-events-dialogs.js +146 -37
  18. package/renderer/app-events-filters.js +172 -9
  19. package/renderer/app-events-navigation.js +78 -31
  20. package/renderer/app-events-sessions.js +171 -4
  21. package/renderer/app-events.js +2 -1
  22. package/renderer/app-graph-model.js +32 -4
  23. package/renderer/app-graph-orchestration.js +16 -13
  24. package/renderer/app-graph-view.js +228 -112
  25. package/renderer/app-management.js +211 -0
  26. package/renderer/app-provider-visibility.js +124 -0
  27. package/renderer/app-quality.js +568 -0
  28. package/renderer/app-run-modal.js +103 -33
  29. package/renderer/app-runtime-overview.js +312 -0
  30. package/renderer/app-session-render.js +94 -37
  31. package/renderer/app-tmux-render.js +119 -38
  32. package/renderer/app.js +155 -76
  33. package/renderer/i18n-messages.js +833 -15
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +162 -75
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +50 -1
  38. package/renderer/styles-cards.css +26 -0
  39. package/renderer/styles-collaboration.css +18 -88
  40. package/renderer/styles-components.css +126 -14
  41. package/renderer/styles-management.css +355 -0
  42. package/renderer/styles-overlays.css +13 -2
  43. package/renderer/styles-product.css +24 -16
  44. package/renderer/styles-quality.css +312 -0
  45. package/renderer/styles-readability.css +862 -0
  46. package/renderer/styles-responsive-product.css +84 -12
  47. package/renderer/styles-responsive-runtime.css +31 -14
  48. package/renderer/styles-responsive-shell.css +44 -48
  49. package/renderer/styles-responsive-workflows.css +37 -17
  50. package/renderer/styles-run-composer.css +5 -0
  51. package/renderer/styles-runtime-overview.css +285 -0
  52. package/renderer/styles-settings.css +160 -0
  53. package/renderer/styles-terminal.css +339 -2
  54. package/renderer/styles-tmux.css +154 -0
  55. package/renderer/styles-workflow-map.css +362 -15
  56. package/renderer/styles-workflows.css +69 -2
  57. package/renderer/styles.css +27 -12
  58. package/renderer/terminal-agent.js +17 -13
  59. package/renderer/terminal-events.js +233 -40
  60. package/renderer/terminal-workbench.js +176 -78
  61. package/renderer/terminal.js +350 -37
  62. package/src/agentMonitor/claudeParser.js +96 -7
  63. package/src/agentMonitor/codexParser.js +59 -4
  64. package/src/agentMonitor/executionActivity.js +282 -0
  65. package/src/agentMonitor/genericParser.js +56 -13
  66. package/src/agentMonitor/hierarchy.js +17 -0
  67. package/src/agentMonitor/responseIntent.js +51 -0
  68. package/src/agentMonitor.js +21 -3
  69. package/src/agentRunner.js +66 -1
  70. package/src/attentionNotifier.js +71 -0
  71. package/src/automationMonitor.js +258 -0
  72. package/src/contracts.js +10 -1
  73. package/src/ipc/registerAgentIpc.js +9 -3
  74. package/src/ipc/registerAppIpc.js +4 -1
  75. package/src/ipc/registerTerminalIpc.js +12 -6
  76. package/src/macUpdateHelper.js +210 -0
  77. package/src/monitorWorker.js +65 -6
  78. package/src/platformPath.js +80 -0
  79. package/src/processMonitor.js +81 -23
  80. package/src/providerVisibilityStore.js +45 -0
  81. package/src/sessionIntelligence.js +247 -0
  82. package/src/terminalHost.js +381 -0
  83. package/src/terminalHostDaemon.js +60 -0
  84. package/src/terminalManager.js +158 -3
  85. package/src/tmuxMonitor.js +2 -2
  86. package/src/updateInstaller.js +175 -0
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const PUBLIC_KEYS = new Set([
7
+ 'version',
8
+ 'id',
9
+ 'kind',
10
+ 'name',
11
+ 'status',
12
+ 'rrule',
13
+ 'model',
14
+ 'reasoning_effort',
15
+ 'execution_environment',
16
+ 'target_thread_id',
17
+ 'cwds',
18
+ 'created_at',
19
+ 'updated_at',
20
+ ]);
21
+
22
+ const WEEKDAYS = Object.freeze({ SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 });
23
+
24
+ function parseQuoted(value) {
25
+ try {
26
+ return JSON.parse(value);
27
+ } catch (_invalidQuotedValue) {
28
+ return value.slice(1, -1);
29
+ }
30
+ }
31
+
32
+ function parseTomlValue(raw) {
33
+ const value = String(raw || '').trim();
34
+ if (value.startsWith('"') && value.endsWith('"')) return parseQuoted(value);
35
+ if (value.startsWith('[') && value.endsWith(']')) {
36
+ const strings = [...value.matchAll(/"(?:\\.|[^"\\])*"/g)].map(match => parseQuoted(match[0]));
37
+ return strings;
38
+ }
39
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
40
+ if (/^(?:true|false)$/i.test(value)) return value.toLowerCase() === 'true';
41
+ return value;
42
+ }
43
+
44
+ /** Parse only non-sensitive fields used by the dashboard. Prompt text is intentionally discarded. */
45
+ function parseAutomationToml(source) {
46
+ const parsed = {};
47
+ let multilineDelimiter = '';
48
+ for (const line of String(source || '').split(/\r?\n/)) {
49
+ if (multilineDelimiter) {
50
+ if (line.includes(multilineDelimiter)) multilineDelimiter = '';
51
+ continue;
52
+ }
53
+ const match = line.match(/^\s*([a-zA-Z][\w]*)\s*=\s*(.*?)\s*$/);
54
+ if (!match) continue;
55
+ const triple = match[2].startsWith('"""') ? '"""' : (match[2].startsWith("'''") ? "'''" : '');
56
+ if (triple) {
57
+ if (!match[2].slice(3).includes(triple)) multilineDelimiter = triple;
58
+ continue;
59
+ }
60
+ if (!PUBLIC_KEYS.has(match[1])) continue;
61
+ parsed[match[1]] = parseTomlValue(match[2]);
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ function parseRRule(value) {
67
+ const output = {};
68
+ for (const pair of String(value || '').split(';')) {
69
+ const [key, raw] = pair.split('=', 2);
70
+ if (key && raw != null) output[key.trim().toUpperCase()] = raw.trim();
71
+ }
72
+ return output;
73
+ }
74
+
75
+ function localCandidate(base, dayOffset, hour, minute, second = 0) {
76
+ const candidate = new Date(base);
77
+ candidate.setHours(hour, minute, second, 0);
78
+ candidate.setDate(candidate.getDate() + dayOffset);
79
+ return candidate;
80
+ }
81
+
82
+ function nextDaily(rule, now, anchor) {
83
+ const hours = String(rule.BYHOUR || '0').split(',').map(Number).filter(Number.isFinite).sort((a, b) => a - b);
84
+ const minutes = String(rule.BYMINUTE || '0').split(',').map(Number).filter(Number.isFinite).sort((a, b) => a - b);
85
+ const interval = Math.max(1, Number(rule.INTERVAL || 1));
86
+ const anchorDay = new Date(anchor || now);
87
+ anchorDay.setHours(0, 0, 0, 0);
88
+ for (let offset = 0; offset <= interval + 2; offset += 1) {
89
+ const day = localCandidate(now, offset, 0, 0);
90
+ day.setHours(0, 0, 0, 0);
91
+ const elapsedDays = Math.round((day.getTime() - anchorDay.getTime()) / 86_400_000);
92
+ if (elapsedDays >= 0 && elapsedDays % interval !== 0) continue;
93
+ for (const hour of hours) for (const minute of minutes) {
94
+ const candidate = localCandidate(now, offset, hour, minute);
95
+ if (candidate > now) return candidate;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function nextHourly(rule, now, anchorValue) {
102
+ const interval = Math.max(1, Number(rule.INTERVAL || 1));
103
+ const minutes = String(rule.BYMINUTE || String(now.getMinutes())).split(',').map(Number).filter(Number.isFinite).sort((a, b) => a - b);
104
+ const anchor = new Date(anchorValue || now);
105
+ anchor.setMinutes(0, 0, 0);
106
+ for (let offset = 0; offset <= interval * 2 + 2; offset += 1) {
107
+ for (const minute of minutes) {
108
+ const candidate = new Date(now);
109
+ candidate.setMinutes(minute, 0, 0);
110
+ candidate.setHours(candidate.getHours() + offset);
111
+ const candidateHour = new Date(candidate);
112
+ candidateHour.setMinutes(0, 0, 0);
113
+ const elapsedHours = Math.round((candidateHour.getTime() - anchor.getTime()) / 3_600_000);
114
+ if (candidate > now && elapsedHours >= 0 && elapsedHours % interval === 0) return candidate;
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function nextWeekly(rule, now, anchorValue) {
121
+ const days = String(rule.BYDAY || Object.keys(WEEKDAYS)[now.getDay()]).split(',')
122
+ .map(value => WEEKDAYS[value.slice(-2).toUpperCase()])
123
+ .filter(Number.isFinite);
124
+ const interval = Math.max(1, Number(rule.INTERVAL || 1));
125
+ const hour = Number(String(rule.BYHOUR || '0').split(',')[0]);
126
+ const minute = Number(String(rule.BYMINUTE || '0').split(',')[0]);
127
+ const anchorWeek = new Date(anchorValue || now);
128
+ anchorWeek.setHours(0, 0, 0, 0);
129
+ anchorWeek.setDate(anchorWeek.getDate() - anchorWeek.getDay());
130
+ for (let offset = 0; offset <= interval * 14 + 7; offset += 1) {
131
+ const candidate = localCandidate(now, offset, hour, minute);
132
+ const candidateWeek = new Date(candidate);
133
+ candidateWeek.setHours(0, 0, 0, 0);
134
+ candidateWeek.setDate(candidateWeek.getDate() - candidateWeek.getDay());
135
+ const elapsedWeeks = Math.round((candidateWeek.getTime() - anchorWeek.getTime()) / (7 * 86_400_000));
136
+ if (days.includes(candidate.getDay()) && candidate > now && elapsedWeeks >= 0 && elapsedWeeks % interval === 0) return candidate;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function nextMinutely(rule, now, anchorValue) {
142
+ const interval = Math.max(1, Number(rule.INTERVAL || 1));
143
+ const anchor = new Date(anchorValue || now);
144
+ anchor.setSeconds(0, 0);
145
+ for (let offset = 1; offset <= interval * 2 + 1; offset += 1) {
146
+ const candidate = new Date(now);
147
+ candidate.setSeconds(0, 0);
148
+ candidate.setMinutes(candidate.getMinutes() + offset);
149
+ const elapsedMinutes = Math.round((candidate.getTime() - anchor.getTime()) / 60_000);
150
+ if (elapsedMinutes >= 0 && elapsedMinutes % interval === 0) return candidate;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ function nextOccurrence(rrule, nowValue = new Date(), anchorValue = null) {
156
+ const now = nowValue instanceof Date ? new Date(nowValue) : new Date(nowValue);
157
+ if (Number.isNaN(now.getTime())) return null;
158
+ const rule = parseRRule(rrule);
159
+ let candidate = null;
160
+ if (rule.FREQ === 'DAILY') candidate = nextDaily(rule, now, anchorValue);
161
+ else if (rule.FREQ === 'HOURLY') candidate = nextHourly(rule, now, anchorValue);
162
+ else if (rule.FREQ === 'WEEKLY') candidate = nextWeekly(rule, now, anchorValue);
163
+ else if (rule.FREQ === 'MINUTELY') candidate = nextMinutely(rule, now, anchorValue);
164
+ if (!candidate) return null;
165
+ if (rule.UNTIL) {
166
+ const until = new Date(rule.UNTIL);
167
+ if (!Number.isNaN(until.getTime()) && candidate > until) return null;
168
+ }
169
+ return candidate.toISOString();
170
+ }
171
+
172
+ function isoTimestamp(value) {
173
+ const number = Number(value || 0);
174
+ if (!Number.isFinite(number) || number <= 0) return null;
175
+ const millis = number > 10_000_000_000 ? number : number * 1000;
176
+ const date = new Date(millis);
177
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
178
+ }
179
+
180
+ function normalizeAutomation(config, file, now) {
181
+ const createdAt = isoTimestamp(config.created_at);
182
+ const status = String(config.status || 'INACTIVE').toUpperCase();
183
+ return {
184
+ id: String(config.id || path.basename(path.dirname(file))),
185
+ kind: String(config.kind || 'automation'),
186
+ name: String(config.name || config.id || 'Codex automation'),
187
+ status,
188
+ enabled: status === 'ACTIVE',
189
+ rrule: String(config.rrule || ''),
190
+ nextRunAt: status === 'ACTIVE' ? nextOccurrence(config.rrule, now, createdAt) : null,
191
+ provider: 'codex',
192
+ model: String(config.model || ''),
193
+ reasoningEffort: String(config.reasoning_effort || ''),
194
+ executionEnvironment: String(config.execution_environment || ''),
195
+ targetThreadId: String(config.target_thread_id || ''),
196
+ cwds: Array.isArray(config.cwds) ? config.cwds.slice(0, 8).map(String) : [],
197
+ createdAt,
198
+ updatedAt: isoTimestamp(config.updated_at),
199
+ };
200
+ }
201
+
202
+ function scanCodexAutomations(options = {}) {
203
+ const home = options.home || '';
204
+ const now = options.now instanceof Date ? options.now : new Date(options.now || Date.now());
205
+ const root = options.root || path.join(home, '.codex', 'automations');
206
+ if (!root || !fs.existsSync(root)) return [];
207
+ let entries = [];
208
+ try {
209
+ entries = fs.readdirSync(root, { withFileTypes: true });
210
+ } catch (_unreadableAutomationDirectory) {
211
+ return [];
212
+ }
213
+ const automations = [];
214
+ for (const entry of entries) {
215
+ if (!entry.isDirectory()) continue;
216
+ const file = path.join(root, entry.name, 'automation.toml');
217
+ try {
218
+ const source = fs.readFileSync(file, 'utf8');
219
+ automations.push(normalizeAutomation(parseAutomationToml(source), file, now));
220
+ } catch (_unreadableAutomationFile) {
221
+ // One malformed automation must not hide the other schedules.
222
+ }
223
+ }
224
+ return automations.sort((a, b) => {
225
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
226
+ return Date.parse(a.nextRunAt || 0) - Date.parse(b.nextRunAt || 0) || a.name.localeCompare(b.name);
227
+ });
228
+ }
229
+
230
+ function scanCodexAutomationHomes(options = {}) {
231
+ const now = options.now instanceof Date ? options.now : new Date(options.now || Date.now());
232
+ const seen = new Set();
233
+ const homes = (options.homes || []).filter((entry) => {
234
+ const key = String(entry && entry.home || '').toLowerCase();
235
+ if (!key || seen.has(key)) return false;
236
+ seen.add(key);
237
+ return true;
238
+ });
239
+ return homes.flatMap((entry) => scanCodexAutomations({ home: entry.home, now }).map((automation) => ({
240
+ ...automation,
241
+ environment: {
242
+ kind: String(entry.kind || 'external'),
243
+ distro: String(entry.distro || ''),
244
+ },
245
+ sourceLabel: String(entry.label || entry.distro || entry.kind || ''),
246
+ }))).sort((a, b) => {
247
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
248
+ return Date.parse(a.nextRunAt || 0) - Date.parse(b.nextRunAt || 0) || a.name.localeCompare(b.name);
249
+ });
250
+ }
251
+
252
+ module.exports = {
253
+ nextOccurrence,
254
+ parseAutomationToml,
255
+ parseRRule,
256
+ scanCodexAutomationHomes,
257
+ scanCodexAutomations,
258
+ };
package/src/contracts.js CHANGED
@@ -37,11 +37,20 @@
37
37
  * @property {string} status
38
38
  * @property {string|null} parentId
39
39
  * @property {string} cwd
40
+ * @property {string} originCwd Immutable workspace path where the session began.
40
41
  * @property {string} updatedAt
41
42
  * @property {TokenUsage} usage
42
43
  * @property {Array<Object>} messages
43
44
  * @property {Array<Object>} lifecycle
45
+ * @property {Array<{id:string,kind:'shell'|'background',mode:'foreground'|'background',tool:string,runtime:string,label:string,command:string,cwd:string,status:'running'|'completed'|'failed',statusDetail:string,output:string,backgroundId:string,exitCode:number|null,startedAt:string|null,updatedAt:string|null,completedAt:string|null}>} executions Observed shell and background execution units owned by this AI session.
44
46
  * @property {CollaborationSummary|null} collaboration
47
+ * @property {{kind:string,iteration:number,phase?:string}|boolean|null} loop Safe execution-loop metadata; internal goal text is never included.
48
+ * @property {{required:boolean,kind:string,summary:string,requestedAt:string|null,source:string,confidence:string}} attention Actionable reason why the session needs the user.
49
+ * @property {{stage:string,percent:number,completedSteps:number,failedSteps:number,totalSteps:number,currentStep:string,blocker:string,lastActivityAt:string|null,source:string,checkpoints:Array<Object>}} progress Structured progress reconstructed from lifecycle events.
50
+ * @property {{level:string,score:number,signals:Array<Object>,lastActivityAt:string|null,ageSeconds:number|null}} health Stalls, failures, context pressure, and hierarchy anomalies.
51
+ * @property {{managed:boolean,respond:boolean,approve:boolean,deny:boolean,sendInstruction:boolean,stop:boolean,pause:boolean,resume:boolean,retry:boolean,reassign:boolean,openOrigin:boolean}} controlCapabilities Safe provider-aware actions available for this session.
52
+ * @property {{confidence:string,status:string,hierarchy:string,completion:string,sources:Array<string>}} evidence Observation provenance and confidence.
53
+ * @property {{status:string,summary:string,verified:boolean,verification:string,completedAt:string|null,artifacts:Array<Object>,checks:Array<Object>}} outcome Completion summary, artifacts, and verification evidence.
45
54
  */
46
55
 
47
56
  /**
@@ -61,7 +70,7 @@
61
70
  * @property {Array<Object>} providers
62
71
  * @property {Object<string, boolean|string>} availability
63
72
  * @property {Array<{path:string,name:string}>} workspaces
64
- * @property {{sessions: AgentSession[], summary: Object}} snapshot
73
+ * @property {{sessions: AgentSession[], automations: Array<Object>, summary: Object}} snapshot Safe local/WSL automation metadata excludes prompt text.
65
74
  * @property {Array<Object>} activeRuns
66
75
  * @property {{app:string,electron:string,node:string}} versions
67
76
  * @property {Object} platform
@@ -1,11 +1,17 @@
1
1
  'use strict';
2
2
 
3
- function registerAgentIpc({ handleTrusted, snapshot, requestDetail, runner, probeProviders }) {
3
+ function registerAgentIpc({ handleTrusted, snapshot, requestDetail, runner, isProviderVisible = () => true, probeProviders }) {
4
4
  handleTrusted('agents:snapshot', snapshot);
5
5
  handleTrusted('agents:detail', requestDetail);
6
- handleTrusted('agents:run', options => runner().start(options));
6
+ handleTrusted('agents:run', options => {
7
+ if (!isProviderVisible(options && options.provider)) throw new Error('설정에서 숨긴 AI는 실행할 수 없습니다.');
8
+ return runner().start(options);
9
+ });
7
10
  handleTrusted('agents:stop', runId => runner().stop(runId));
8
- handleTrusted('agents:active-runs', () => runner().listActive());
11
+ handleTrusted('agents:pause', runId => runner().pause(runId));
12
+ handleTrusted('agents:resume-run', runId => runner().resume(runId));
13
+ handleTrusted('agents:retry', runId => runner().retry(runId));
14
+ handleTrusted('agents:active-runs', () => runner().listActive().filter(run => isProviderVisible(run.provider)));
9
15
  handleTrusted('providers:probe', probeProviders);
10
16
  }
11
17
 
@@ -1,13 +1,16 @@
1
1
  'use strict';
2
2
 
3
- function registerAppIpc({ handleTrusted, bootstrap, backgroundState, show, setLocale, updateManager }) {
3
+ function registerAppIpc({ handleTrusted, bootstrap, rendererReady, backgroundState, show, setLocale, setProviderVisibility, updateManager, installUpdate }) {
4
4
  handleTrusted('app:bootstrap', bootstrap);
5
+ handleTrusted('app:renderer-ready', rendererReady);
5
6
  handleTrusted('app:background-state', backgroundState);
6
7
  handleTrusted('app:show', show);
7
8
  handleTrusted('app:set-locale', setLocale);
9
+ handleTrusted('app:set-provider-visibility', setProviderVisibility);
8
10
  handleTrusted('app:update-check', () => requireUpdateManager(updateManager).check());
9
11
  handleTrusted('app:update-download', () => requireUpdateManager(updateManager).download());
10
12
  handleTrusted('app:update-open', () => requireUpdateManager(updateManager).openDownloaded());
13
+ handleTrusted('app:update-install', installUpdate);
11
14
  handleTrusted('app:update-open-release', () => requireUpdateManager(updateManager).openReleasePage());
12
15
  }
13
16
 
@@ -1,25 +1,31 @@
1
1
  'use strict';
2
2
 
3
- function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, manager, listWslDistros, sendError }) {
3
+ function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, manager, isProviderVisible = () => true, listWslDistros, sendError }) {
4
4
  ipcMain.handle('terminals:list', event => {
5
5
  requireTrustedSender(event);
6
- return manager() ? manager().list() : [];
6
+ return manager() ? manager().list().filter(session => session.type !== 'agent' || isProviderVisible(session.provider)) : [];
7
7
  });
8
8
  ipcMain.handle('wsl:list-distros', event => {
9
9
  requireTrustedSender(event);
10
10
  return listWslDistros();
11
11
  });
12
- ipcMain.handle('terminals:get', (event, id) => {
12
+ ipcMain.handle('terminals:get', async (event, id) => {
13
13
  requireTrustedSender(event);
14
- return manager() ? manager().get(id, true) : null;
14
+ const session = manager() ? await manager().get(id, true) : null;
15
+ return session && session.type === 'agent' && !isProviderVisible(session.provider) ? null : session;
15
16
  });
16
17
  ipcMain.handle('terminals:create', (event, options) => {
17
18
  requireTrustedSender(event);
19
+ if (options && options.type === 'agent' && !isProviderVisible(options.provider)) throw new Error('설정에서 숨긴 AI는 실행할 수 없습니다.');
18
20
  return requireManager(manager).create(options || {});
19
21
  });
20
22
  ipcMain.on('terminals:write', (event, id, data) => {
21
23
  if (!trustedSender(event) || !manager()) return;
22
- try { manager().write(id, data); } catch (error) { sendError({ id: String(id || ''), message: error.message }); }
24
+ try {
25
+ Promise.resolve(manager().write(id, data)).catch(error => sendError({ id: String(id || ''), message: error.message }));
26
+ } catch (error) {
27
+ sendError({ id: String(id || ''), message: error.message });
28
+ }
23
29
  });
24
30
  ipcMain.handle('terminals:command', (event, id, command) => {
25
31
  requireTrustedSender(event);
@@ -28,7 +34,7 @@ function registerTerminalIpc({ ipcMain, requireTrustedSender, trustedSender, man
28
34
  ipcMain.on('terminals:resize', (event, id, cols, rows) => {
29
35
  if (!trustedSender(event) || !manager()) return;
30
36
  try {
31
- manager().resize(id, cols, rows);
37
+ Promise.resolve(manager().resize(id, cols, rows)).catch(error => sendError({ id: String(id || ''), message: error.message }));
32
38
  } catch (error) {
33
39
  sendError({ id: String(id || ''), message: error.message });
34
40
  }
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { spawn } = require('child_process');
8
+
9
+ const DEFAULT_COMMANDS = Object.freeze({
10
+ hdiutil: '/usr/bin/hdiutil',
11
+ ditto: '/usr/bin/ditto',
12
+ open: '/usr/bin/open',
13
+ });
14
+
15
+ function delay(milliseconds) {
16
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
17
+ }
18
+
19
+ function runCommand(command, args = []) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = spawn(command, args, { stdio: 'ignore' });
22
+ child.once('error', reject);
23
+ child.once('exit', (code, signal) => {
24
+ if (code === 0) resolve();
25
+ else reject(new Error(`${path.basename(command)} 종료 코드: ${code == null ? signal : code}`));
26
+ });
27
+ });
28
+ }
29
+
30
+ async function waitForParentExit(parentPid, options = {}) {
31
+ const pid = Number(parentPid);
32
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('종료를 기다릴 앱 프로세스 정보가 올바르지 않습니다.');
33
+ const processExists = options.processExists || (candidate => {
34
+ try {
35
+ process.kill(candidate, 0);
36
+ return true;
37
+ } catch (error) {
38
+ if (error && error.code === 'ESRCH') return false;
39
+ if (error && error.code === 'EPERM') return true;
40
+ throw error;
41
+ }
42
+ });
43
+ const pause = options.delay || delay;
44
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 60_000;
45
+ const pollMs = Number(options.pollMs) > 0 ? Number(options.pollMs) : 250;
46
+ const startedAt = Date.now();
47
+ while (processExists(pid)) {
48
+ if (Date.now() - startedAt >= timeoutMs) throw new Error('기존 앱이 제한 시간 안에 종료되지 않았습니다.');
49
+ await pause(pollMs);
50
+ }
51
+ }
52
+
53
+ async function appendLog(logPath, message, fileSystem = fs.promises) {
54
+ if (!logPath) return;
55
+ try {
56
+ await fileSystem.appendFile(logPath, `[${new Date().toISOString()}] ${message}\n`, 'utf8');
57
+ } catch (_logError) {
58
+ // Logging must never prevent rollback or relaunch.
59
+ }
60
+ }
61
+
62
+ async function pathIsDirectory(targetPath, fileSystem = fs.promises) {
63
+ try {
64
+ const stat = await fileSystem.lstat(targetPath);
65
+ return stat.isDirectory() && !stat.isSymbolicLink();
66
+ } catch (_missingPath) {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async function removePath(targetPath, fileSystem = fs.promises) {
72
+ if (!targetPath) return;
73
+ await fileSystem.rm(targetPath, { recursive: true, force: true });
74
+ }
75
+
76
+ async function installMacUpdate(options = {}) {
77
+ const dmgPath = String(options.dmgPath || '');
78
+ const targetApp = String(options.targetApp || '');
79
+ const logPath = String(options.logPath || '');
80
+ const fileSystem = options.fileSystem || fs.promises;
81
+ const run = options.run || runCommand;
82
+ const wait = options.waitForParentExit || waitForParentExit;
83
+ const commands = { ...DEFAULT_COMMANDS, ...(options.commands || {}) };
84
+ const operationId = String(options.operationId || `${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
85
+ .replace(/[^0-9A-Za-z-]/g, '') || String(process.pid);
86
+ const targetParent = path.dirname(targetApp);
87
+ const targetName = path.basename(targetApp);
88
+ let mountPath = String(options.mountPath || '');
89
+ if (!dmgPath || !targetApp || !logPath) throw new Error('macOS 업데이트에 필요한 경로가 비어 있습니다.');
90
+ if (!await pathIsDirectory(targetApp, fileSystem)) throw new Error('현재 설치된 앱 번들을 찾지 못했습니다.');
91
+ if (!mountPath) mountPath = await fileSystem.mkdtemp(path.join(os.tmpdir(), 'loadtoagent-update-'));
92
+ const stagedApp = path.join(targetParent, `.${targetName}.update-${operationId}`);
93
+ const backupApp = path.join(targetParent, `.${targetName}.backup-${operationId}`);
94
+ let mounted = false;
95
+ let parentExited = false;
96
+ let oldAppMoved = false;
97
+ let newAppMoved = false;
98
+
99
+ await appendLog(logPath, `waiting parentPid=${Number(options.parentPid)}`, fileSystem);
100
+ try {
101
+ await wait(Number(options.parentPid));
102
+ parentExited = true;
103
+ await appendLog(logPath, 'parent exited', fileSystem);
104
+
105
+ await removePath(stagedApp, fileSystem);
106
+ await removePath(backupApp, fileSystem);
107
+ await run(commands.hdiutil, ['attach', dmgPath, '-nobrowse', '-readonly', '-mountpoint', mountPath]);
108
+ mounted = true;
109
+
110
+ const sourceApp = path.join(mountPath, 'LoadToAgent.app');
111
+ if (!await pathIsDirectory(sourceApp, fileSystem)) throw new Error('DMG에서 LoadToAgent.app을 찾지 못했습니다.');
112
+ await run(commands.ditto, [sourceApp, stagedApp]);
113
+ if (!await pathIsDirectory(stagedApp, fileSystem)) throw new Error('새 앱을 설치 위치에 복사하지 못했습니다.');
114
+
115
+ try {
116
+ await run(commands.hdiutil, ['detach', mountPath]);
117
+ mounted = false;
118
+ } catch (error) {
119
+ await appendLog(logPath, `detach warning: ${error && error.message || error}`, fileSystem);
120
+ }
121
+
122
+ await fileSystem.rename(targetApp, backupApp);
123
+ oldAppMoved = true;
124
+ await fileSystem.rename(stagedApp, targetApp);
125
+ newAppMoved = true;
126
+ await run(commands.open, ['-n', targetApp]);
127
+ await appendLog(logPath, 'update installed and relaunched', fileSystem);
128
+
129
+ try {
130
+ await removePath(backupApp, fileSystem);
131
+ } catch (error) {
132
+ await appendLog(logPath, `backup cleanup warning: ${error && error.message || error}`, fileSystem);
133
+ }
134
+ return { targetApp };
135
+ } catch (error) {
136
+ await appendLog(logPath, `update failed: ${error && error.stack || error}`, fileSystem);
137
+ if (oldAppMoved) {
138
+ try {
139
+ if (newAppMoved) await removePath(targetApp, fileSystem);
140
+ if (await pathIsDirectory(backupApp, fileSystem)) await fileSystem.rename(backupApp, targetApp);
141
+ await appendLog(logPath, 'original app restored', fileSystem);
142
+ } catch (rollbackError) {
143
+ await appendLog(logPath, `rollback failed: ${rollbackError && rollbackError.stack || rollbackError}`, fileSystem);
144
+ }
145
+ }
146
+ if (parentExited && await pathIsDirectory(targetApp, fileSystem)) {
147
+ try {
148
+ await run(commands.open, ['-n', targetApp]);
149
+ await appendLog(logPath, 'original app relaunched', fileSystem);
150
+ } catch (relaunchError) {
151
+ await appendLog(logPath, `relaunch failed: ${relaunchError && relaunchError.stack || relaunchError}`, fileSystem);
152
+ }
153
+ }
154
+ throw error;
155
+ } finally {
156
+ if (mounted) {
157
+ try {
158
+ await run(commands.hdiutil, ['detach', mountPath, '-force']);
159
+ } catch (error) {
160
+ await appendLog(logPath, `forced detach failed: ${error && error.message || error}`, fileSystem);
161
+ }
162
+ }
163
+ try { await removePath(stagedApp, fileSystem); } catch (_cleanupError) {}
164
+ try { await removePath(mountPath, fileSystem); } catch (_cleanupError) {}
165
+ }
166
+ }
167
+
168
+ function parseArguments(argv) {
169
+ const values = {};
170
+ for (let index = 0; index < argv.length; index += 2) {
171
+ const flag = argv[index];
172
+ const value = argv[index + 1];
173
+ if (!/^--(?:dmg|target|parent-pid|log)$/.test(String(flag || '')) || value == null) {
174
+ throw new Error('macOS 업데이트 헬퍼 인자가 올바르지 않습니다.');
175
+ }
176
+ values[flag.slice(2)] = value;
177
+ }
178
+ return {
179
+ dmgPath: values.dmg,
180
+ targetApp: values.target,
181
+ parentPid: Number(values['parent-pid']),
182
+ logPath: values.log,
183
+ };
184
+ }
185
+
186
+ async function runCli(argv = process.argv.slice(2)) {
187
+ const options = parseArguments(argv);
188
+ delete process.env.ELECTRON_RUN_AS_NODE;
189
+ try {
190
+ await installMacUpdate(options);
191
+ } catch (error) {
192
+ await appendLog(options.logPath, `helper stopped: ${error && error.stack || error}`);
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ if (require.main === module) {
198
+ runCli().catch(() => { process.exitCode = 1; });
199
+ }
200
+
201
+ module.exports = {
202
+ DEFAULT_COMMANDS,
203
+ appendLog,
204
+ installMacUpdate,
205
+ parseArguments,
206
+ pathIsDirectory,
207
+ runCommand,
208
+ runCli,
209
+ waitForParentExit,
210
+ };