@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.5

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/bin/projectfreeze.js +4 -0
  4. package/docs/interfaces.md +98 -0
  5. package/docs/operations.md +42 -0
  6. package/examples/demo.js +72 -0
  7. package/examples/stream-test.mjs +71 -0
  8. package/examples/workload.js +29 -0
  9. package/helpers/.gitkeep +0 -0
  10. package/helpers/projectfreeze-darwin-arm64 +0 -0
  11. package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
  12. package/helpers/projectfreeze-darwin-x64 +0 -0
  13. package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
  14. package/helpers/projectfreeze-linux-arm64 +0 -0
  15. package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
  16. package/helpers/projectfreeze-linux-x64 +0 -0
  17. package/helpers/projectfreeze-linux-x64.sha256 +1 -0
  18. package/helpers/projectfreeze-win32-arm64.exe +0 -0
  19. package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
  20. package/helpers/projectfreeze-win32-x64.exe +0 -0
  21. package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
  22. package/package.json +17 -4
  23. package/src/cli.js +135 -0
  24. package/src/contracts.d.ts +28 -0
  25. package/src/discovery.js +112 -0
  26. package/src/engine.js +820 -0
  27. package/src/errors.js +20 -0
  28. package/src/integrations.js +195 -0
  29. package/src/inventory.js +37 -0
  30. package/src/ipc.js +258 -0
  31. package/src/line-menu.js +147 -0
  32. package/src/mcp.js +113 -0
  33. package/src/platform.js +320 -0
  34. package/src/recipe.js +69 -0
  35. package/src/store.js +61 -0
  36. package/src/supervisors.js +163 -0
  37. package/src/systemd.js +104 -0
  38. package/src/tui.js +596 -0
  39. package/src/windows-supervisors.ps1 +83 -0
package/src/tui.js ADDED
@@ -0,0 +1,596 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+ import manifest from '../package.json' with { type: 'json' };
5
+ import { problem } from './errors.js';
6
+ import { request, waitOperation } from './ipc.js';
7
+ import { identity, key as processKey } from './platform.js';
8
+
9
+ const esc = '\x1b[';
10
+ const clean = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, ' ');
11
+ const segment = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
12
+ const wide = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u1100-\u115f\u2329\u232a\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]/u;
13
+ const glyphs = value => Array.from(segment.segment(value), entry => entry.segment);
14
+ const columns = value => wide.test(value) ? 2 : 1;
15
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
16
+ const ports = entries => [...new Set((entries ?? []).map(row => row.port))].join(', ');
17
+ const endpoint = row => `${row.address.includes(':') ? `[${row.address}]` : row.address}:${row.port}`;
18
+ const mutations = {
19
+ freeze: ['Freeze', 'Suspend reviewed processes. Memory and listening sockets stay allocated.'],
20
+ resume: ['Resume', 'Resume the processes suspended by ProjectFreeze.'],
21
+ sleep: ['Sleep', 'Shut down reviewed processes. Shared listeners may remain open.'],
22
+ kill: ['Force termination', 'Stop the reviewed native processes immediately. Unsaved work may be lost.'],
23
+ wake: ['Wake', 'Start the saved recipe. Expect a new PID and reconnect to the workload.'],
24
+ checkpoint: ['Checkpoint', 'Capture supported process state into private storage.'],
25
+ restore: ['Restore', 'Restore a verified checkpoint into an inactive workload.'],
26
+ reconcile: ['Reconcile', 'Check current process identities against the operation journal.']
27
+ };
28
+ const shortcuts = { f: 'freeze', u: 'resume', z: 'sleep', k: 'kill', w: 'wake', c: 'checkpoint', o: 'restore', e: 'reconcile' };
29
+ const filters = ['all', 'running', 'discovered', 'sleeping', 'frozen', 'partial'];
30
+ const sorts = ['activity', 'name', 'ports', 'CPU'];
31
+ const helpSections = [
32
+ ['Navigation', [
33
+ ['↑ / ↓', 'Move or scroll'],
34
+ ['PgUp / PgDn', 'Move by one page'],
35
+ ['Home / End', 'Jump to first or last row'],
36
+ ['Enter', 'Open or confirm'],
37
+ ['Esc', 'Go back or cancel'],
38
+ ['?', 'Open or close help'],
39
+ ['q', 'Quit or detach']
40
+ ]],
41
+ ['Workloads', [
42
+ ['l', 'Launch a saved recipe'],
43
+ ['a', 'Review process ownership'],
44
+ ['Space', 'Expand or collapse the process tree in details'],
45
+ ['f', 'Freeze processes'],
46
+ ['u', 'Resume frozen processes'],
47
+ ['z', 'Sleep gracefully'],
48
+ ['w', 'Wake from a saved recipe'],
49
+ ['k', 'Force terminate (two confirmations)']
50
+ ]],
51
+ ['Views and selection', [
52
+ ['Tab / Shift+Tab', 'Switch views'],
53
+ ['← / →', 'Switch views from a list'],
54
+ ['p', 'Open Ports (read-only)'],
55
+ ['v', 'Open Services (read-only)'],
56
+ ['h', 'Open History'],
57
+ ['/', 'Search the current view'],
58
+ ['Space', 'Toggle project selection'],
59
+ ['t', 'Filter projects'],
60
+ ['s', 'Sort projects'],
61
+ ['r', 'Refresh the current view']
62
+ ]],
63
+ ['Recovery', [
64
+ ['c', 'Create a checkpoint'],
65
+ ['o', 'Restore a checkpoint'],
66
+ ['e', 'Reconcile project state'],
67
+ ['x', 'Request operation cancellation']
68
+ ]]
69
+ ];
70
+
71
+ function fit(value, width) {
72
+ const parts = glyphs(clean(value));
73
+ const total = parts.reduce((sum, char) => sum + columns(char), 0);
74
+ let text = '';
75
+ let used = 0;
76
+ const limit = total > width ? Math.max(0, width - 1) : width;
77
+ for (const char of parts) {
78
+ const cells = columns(char);
79
+ if (used + cells > limit) break;
80
+ text += char;
81
+ used += cells;
82
+ }
83
+ if (total > width && width) { text += '…'; used++; }
84
+ return text + ' '.repeat(Math.max(0, width - used));
85
+ }
86
+
87
+ function helpGroup([heading, items], span) {
88
+ const keyWidth = 15;
89
+ const inline = span >= 32;
90
+ const continuation = ' '.repeat(inline ? keyWidth + 4 : 4);
91
+ const available = Math.max(1, span - continuation.length);
92
+ const rows = [{ text: heading, tone: '1;36' }];
93
+ for (const [key, description] of items) {
94
+ const wrapped = [];
95
+ for (const word of description.split(' ')) {
96
+ const last = wrapped.at(-1);
97
+ if (last && last.length + word.length + 1 <= available) wrapped[wrapped.length - 1] += ` ${word}`;
98
+ else wrapped.push(word);
99
+ }
100
+ if (inline) rows.push({ text: ` ${fit(key, keyWidth)} ${wrapped.shift()}` });
101
+ else rows.push({ text: ` ${key}`, tone: '1' });
102
+ rows.push(...wrapped.map(text => ({ text: continuation + text })));
103
+ }
104
+ return rows;
105
+ }
106
+
107
+ function helpRows(span) {
108
+ if (span < 96) return helpSections.flatMap((section, index) => [
109
+ ...(index ? [{ text: '' }] : []), ...helpGroup(section, span)
110
+ ]);
111
+ const gap = ' ';
112
+ const leftWidth = Math.floor((span - gap.length) / 2);
113
+ const rightWidth = span - leftWidth - gap.length;
114
+ const rows = [];
115
+ for (let offset = 0; offset < helpSections.length; offset += 2) {
116
+ const left = helpGroup(helpSections[offset], leftWidth);
117
+ const right = helpGroup(helpSections[offset + 1], rightWidth);
118
+ if (offset) rows.push({ text: '' });
119
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
120
+ rows.push({ text: `${fit(left[index]?.text ?? '', leftWidth)}${gap}${right[index]?.text ?? ''}`, tone: left[index]?.tone ?? right[index]?.tone });
121
+ }
122
+ }
123
+ return rows;
124
+ }
125
+
126
+ function helpPanel(span, budget, dialog) {
127
+ const rows = helpRows(span);
128
+ const capacity = Math.max(1, budget - 2);
129
+ dialog.scroll = clamp(dialog.scroll, 0, rows.length - capacity);
130
+ const end = Math.min(rows.length, dialog.scroll + capacity);
131
+ return [{ text: 'Keyboard shortcuts', tone: '1;36' }, ...rows.slice(dialog.scroll, end),
132
+ { text: rows.length > capacity ? `↑↓ Scroll · ${dialog.scroll + 1}–${end} of ${rows.length}` : '', tone: '2' }];
133
+ }
134
+
135
+ function available(project, action) {
136
+ if (!project) return false;
137
+ if (action === 'freeze') return project.processCount > 0 && project.state !== 'frozen';
138
+ if (action === 'resume') return ['frozen', 'partial'].includes(project.state);
139
+ if (action === 'sleep') return project.restart?.configured && !['sleeping', 'frozen', 'exited'].includes(project.state);
140
+ if (action === 'kill') return project.state !== 'discovered' && project.processCount > 0 &&
141
+ !project.containers?.length && !project.systemd?.length && !project.supervisors?.length;
142
+ if (action === 'wake') return project.restart?.configured && ['sleeping', 'exited', 'partial'].includes(project.state) && project.processCount === 0;
143
+ if (action === 'checkpoint') return project.state !== 'discovered' && project.processCount > 0;
144
+ if (action === 'restore') return project.state !== 'discovered' && project.processCount === 0;
145
+ return project.state !== 'discovered';
146
+ }
147
+
148
+ export async function interactive() {
149
+ const input = process.stdin;
150
+ const output = process.stdout;
151
+ const colors = !Object.hasOwn(process.env, 'NO_COLOR');
152
+ const style = (code, text) => colors ? `${esc}${code}m${text}${esc}0m` : text;
153
+ const selected = new Set();
154
+ const scope = new AbortController();
155
+ const wasRaw = input.isRaw;
156
+ const width = () => Math.max(1, (output.columns || 80) - 1);
157
+ const height = () => Math.max(3, output.rows || 24);
158
+ let tab = 0;
159
+ let projects = [];
160
+ let listeners = [];
161
+ let services = { rows: [], unavailable: [] };
162
+ let history = [];
163
+ let viewKey;
164
+ let viewIndex = 0;
165
+ let search = '';
166
+ let filter = 0;
167
+ let sort = 0;
168
+ let status = 'Scanning local projects…';
169
+ let detail, dialog, operation, refreshPending, timer;
170
+ let scanning = false;
171
+ let handling = false;
172
+ let lastFrame = [];
173
+ let ended = false;
174
+ let close;
175
+ const done = new Promise(resolveClose => { close = resolveClose; });
176
+ const titles = ['Projects', 'Ports', 'Services', 'History'];
177
+
178
+ function entries() {
179
+ const query = search.toLowerCase();
180
+ if (tab === 0) return projects.filter(project =>
181
+ (filter === 0 || project.state === filters[filter] || (filter === 1 && project.processCount > 0 && project.state !== 'frozen')) &&
182
+ `${project.name} ${project.state} ${project.cwd} ${ports(project.ports)} ${project.processes.map(row => row.pid).join(' ')}`.toLowerCase().includes(query)
183
+ ).sort((a, b) => ((sort === 0 && Number(b.processCount > 0) - Number(a.processCount > 0)) ||
184
+ (sort === 2 && b.ports.length - a.ports.length) || (sort === 3 && (b.cpuPercent ?? 0) - (a.cpuPercent ?? 0)) || a.name.localeCompare(b.name)))
185
+ .map(project => ({ id: project.id, project }));
186
+ if (tab === 1) return listeners.filter(row => `${row.protocol} ${endpoint(row)} ${row.pid ?? ''} ${row.name ?? ''} ${row.image ?? ''}`.toLowerCase().includes(query))
187
+ .map(row => ({ id: `${row.protocol}/${endpoint(row)}/${row.pid}`, listener: row }));
188
+ if (tab === 2) return services.rows.filter(row => `${row.name} ${row.manager} ${row.pid ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query))
189
+ .map(row => ({ id: `${row.manager}/${row.name}`, service: row }));
190
+ return history.filter(row => `${row.action} ${row.status} ${row.id}`.includes(query)).map(row => ({ id: row.id, op: row }));
191
+ }
192
+
193
+ function focused(rows) {
194
+ const existing = rows.findIndex(row => row.id === viewKey);
195
+ viewIndex = existing >= 0 ? existing : clamp(viewIndex, 0, rows.length - 1);
196
+ viewKey = rows[viewIndex]?.id;
197
+ return rows[viewIndex];
198
+ }
199
+
200
+ function listLines(span, budget) {
201
+ const rows = entries();
202
+ focused(rows);
203
+ const subheading = tab === 0 ? `${projects.filter(row => row.processCount > 0).length} active / ${projects.length} projects / ${selected.size} selected` :
204
+ tab === 1 ? `${listeners.length} local TCP/UDP endpoints / Read-only` : tab === 2 ?
205
+ `${services.rows.length} running services / Read-only${services.unavailable.length ? ` / Unavailable: ${services.unavailable.join(', ')}` : ''}` :
206
+ `${history.length} recent operations`;
207
+ const lines = [{ text: `${subheading} ${search ? `Search: ${search}` : tab === 0 ? `Filter: ${filters[filter]} Sort: ${sorts[sort]}` : ''}`, tone: '2' }];
208
+ const nameSize = clamp(Math.floor(span * 0.37), 12, 36);
209
+ if (span >= 60) lines.push({ text: tab === 0 ? ` ${fit('PROJECT', nameSize)} ${fit('STATE', 18)} ${fit('PROCS', 6)} TCP` :
210
+ tab === 1 ? ' PROTOCOL / LOCAL ENDPOINT PID PROJECT / PROCESS' : tab === 2 ?
211
+ ` ${fit('SERVICE', nameSize)} ${fit('MANAGER', 24)} PID` : ' ACTION STATUS OPERATION', tone: '36' });
212
+ if (!rows.length) return [...lines, { text: scanning ? ' Scanning…' : ' No matching entries.' }];
213
+ const count = Math.max(1, budget - lines.length);
214
+ const offset = clamp(viewIndex - count + 1, 0, rows.length - count);
215
+ for (const [index, row] of rows.slice(offset, offset + count).entries()) {
216
+ const marked = index + offset === viewIndex;
217
+ const pointer = marked ? '› ' : ' ';
218
+ let text;
219
+ if (row.project) {
220
+ const p = row.project;
221
+ const choice = selected.has(p.id) ? '● ' : ' ';
222
+ text = span >= 60 ? `${choice}${fit(p.name, nameSize)} ${fit(p.state, 18)} ${fit(p.processCount, 6)} ${ports(p.ports) || '—'}` :
223
+ `${choice}${fit(p.name, Math.max(5, Math.min(nameSize, span - p.state.length - 7))).trimEnd()} ${p.state} ${ports(p.ports)}`;
224
+ } else if (row.listener) {
225
+ const p = row.listener;
226
+ const address = `${p.protocol.toUpperCase()} ${endpoint(p)}`;
227
+ text = span >= 60 ? `${fit(address, 38)} ${fit(p.pid ?? '—', 9)} ${p.name ?? basename(p.image ?? 'Unassigned')} (${p.scope})` : `${address} PID ${p.pid ?? '—'}`;
228
+ } else if (row.service) {
229
+ const s = row.service;
230
+ text = span >= 60 ? `${fit(s.name, nameSize)} ${fit(s.manager, 24)} ${s.pid ?? '—'}` : `${s.name} ${s.manager}`;
231
+ } else text = `${fit(row.op.action, 15)} ${fit(row.op.status, 14)} ${row.op.id}`;
232
+ lines.push({ text: pointer + text, tone: marked ? '7' : row.project?.state === 'partial' ? '33' : undefined });
233
+ }
234
+ return lines;
235
+ }
236
+
237
+ function projectPanel({ project, collapsed }) {
238
+ const rows = project.processes;
239
+ const children = new Map();
240
+ for (const row of rows) {
241
+ if (!children.has(row.parent)) children.set(row.parent, []);
242
+ children.get(row.parent).push(row);
243
+ }
244
+ const branches = [];
245
+ const visited = new Set();
246
+ function branch(row, indent) {
247
+ if (visited.has(row.pid)) return;
248
+ visited.add(row.pid);
249
+ branches.push(`${' '.repeat(Math.min(indent, 4))}PID ${row.pid} ${basename(row.image ?? '')} ${ports(row.ports) ? `TCP ${ports(row.ports)}` : ''}`);
250
+ for (const child of children.get(row.pid) ?? []) branch(child, indent + 1);
251
+ }
252
+ const pids = new Set(rows.map(row => row.pid));
253
+ if (!collapsed) {
254
+ rows.filter(row => !pids.has(row.parent)).forEach(row => branch(row, 0));
255
+ rows.forEach(row => branch(row, 0));
256
+ }
257
+ const ownership = { observed: 'Review required', 'reviewed-or-contained': 'Reviewed or contained', registered: 'Registered' };
258
+ const dependencies = project.dependencies.map(id => {
259
+ const dependency = projects.find(row => row.id === id);
260
+ return ` ${dependency ? `${dependency.name} [${dependency.state}] ` : ''}${id}`;
261
+ });
262
+ const scope = target => target.shared ? 'shared' : 'exclusive';
263
+ const resources = [
264
+ ...project.containers.map(target => ` Compose ${target.project}/${target.service} (${scope(target)})`),
265
+ ...project.systemd.map(target => ` systemd ${target.unit} (${scope(target)})`),
266
+ ...project.supervisors.map(target => ` ${target.kind} ${target.name} (${scope(target)}) ${target.error?.message ?? target.state ?? ''}`)
267
+ ];
268
+ const operations = (project.operations ?? []).filter(item => item.status !== 'complete').slice(0, 3)
269
+ .map(item => ` ${item.action} [${item.status}] ${item.pending ?? item.outcomes.find(row => row.status === 'failed')?.message ?? item.id}`);
270
+ const actionGuide = Object.entries(shortcuts).filter(([, action]) => available(project, action))
271
+ .map(([key, action]) => `${key} ${mutations[action][0]}`);
272
+ if (project.candidates?.length) actionGuide.unshift('a Review ownership');
273
+ return [`${project.name} [${project.state}]`, project.cwd, `Ownership: ${ownership[project.confidence] ?? 'Unknown'} · ${project.id}`, '',
274
+ `TCP listeners: ${project.ports.map(endpoint).join(', ') || 'none'}`,
275
+ `${project.processCount} processes CPU ${project.cpuPercent?.toFixed(1) ?? '—'}% RSS ${project.rssSum === undefined ? '—' : `${(project.rssSum / 2 ** 20).toFixed(1)} MiB`}`,
276
+ `Restart recipe: ${project.restart?.configured ? 'saved' : 'unavailable'} Candidates: ${project.candidates?.length ?? 0}`, '',
277
+ actionGuide.join(' '), '',
278
+ ...(dependencies.length ? ['Dependencies', ...dependencies, ''] : []),
279
+ ...(resources.length ? ['Registered resources', ...resources, ''] : []),
280
+ ...(operations.length ? ['Recent incomplete operations', ...operations, ''] : []),
281
+ ...(rows.length ? [`Space ${collapsed ? 'Expand' : 'Collapse'} process tree (${rows.length})`, ...branches] : [])];
282
+ }
283
+
284
+ function panelLines(budget) {
285
+ if (dialog?.kind === 'help') return helpPanel(width(), budget, dialog);
286
+ const lines = dialog?.kind === 'input' ? [dialog.title, '', `› ${glyphs(dialog.text).slice(-Math.max(1, Math.floor((width() - 3) / 2))).join('')}`, '', 'Enter Apply Esc Cancel'] :
287
+ dialog?.kind === 'confirm' ? [dialog.title, '', ...dialog.lines] :
288
+ dialog?.kind === 'adopt' ? ['Review ownership', 'Space toggles a process. Enter continues.', '',
289
+ ...dialog.rows.map(row => `${dialog.approved.has(processKey(row)) ? '[x]' : '[ ]'} PID ${row.pid} ${basename(row.image ?? '')} TCP ${ports(row.ports) || '—'}`)] :
290
+ detail?.project ? projectPanel(detail) : detail?.listener ? [`${detail.listener.protocol.toUpperCase()} ${endpoint(detail.listener)}`, '', `PID ${detail.listener.pid ?? 'unknown'}`, detail.listener.image ?? '',
291
+ `Project: ${detail.listener.name ?? 'Unassigned'}`, `Scope: ${detail.listener.scope}`, '', ...(detail.listener.project ? ['Enter Open project'] : [])] :
292
+ detail?.service ? [detail.service.name, detail.service.description ?? '', '', detail.service.manager, `PID ${detail.service.pid ?? 'unknown'}`, 'Read-only'] :
293
+ [`${detail?.op.action} [${detail?.op.status}]`, detail?.op.id, '', ...(detail?.op.outcomes ?? []).map(row => `${row.status} ${row.target} ${row.message ?? ''}`)];
294
+ const panel = dialog ?? detail;
295
+ const choice = dialog?.kind === 'adopt' ? dialog.index + 3 : undefined;
296
+ const confirm = dialog?.kind === 'confirm';
297
+ const cap = Math.max(1, budget - (confirm ? 1 : 0));
298
+ panel.scroll = clamp(choice === undefined ? panel.scroll ?? 0 : Math.max(panel.scroll ?? 0, choice - cap + 1), 0, lines.length - cap);
299
+ if (choice !== undefined && choice < panel.scroll) panel.scroll = choice;
300
+ const visible = lines.slice(panel.scroll, panel.scroll + cap).map((line, index) => ({ text: line,
301
+ tone: index + panel.scroll === choice ? '7' : index + panel.scroll === 0 ? '1;36' : undefined }));
302
+ if (confirm) visible.push({ text: 'Enter Confirm Esc Cancel', tone: '1;36' });
303
+ return visible;
304
+ }
305
+
306
+ function draw() {
307
+ if (ended) return;
308
+ const span = width();
309
+ const rows = height();
310
+ const tabs = titles.map((title, index) => index === tab ? `[${title}]` : title).join(' ');
311
+ const budget = Math.max(1, rows - 5);
312
+ const body = dialog || detail ? panelLines(budget) : listLines(span, budget);
313
+ const heading = [{ text: ` ProjectFreeze ${manifest.version} `, tone: '1;36' }, { text: tabs, tone: '1' }];
314
+ const result = operation ? `${operation.action} ${operation.status ?? 'starting'} ${operation.outcomes?.length ?? 0} result(s) ${operation.pending ?? ''}` : status;
315
+ const controls = dialog?.kind === 'help' ? 'Esc Back ? Close help' : operation ? 'x Cancel operation q Detach' : dialog ? 'Esc Cancel q Quit' : detail ?
316
+ '↑↓ Scroll Esc Back Tab View ? Help q Quit' : 'Tab View ↑↓ Move Enter Inspect / Search l Launch ? Help q Quit';
317
+ const frame = [...heading, ...body.slice(0, budget)];
318
+ while (frame.length < rows - 2) frame.push({ text: '' });
319
+ frame.push({ text: result, tone: operation ? '33' : '2' }, { text: controls, tone: '36' });
320
+ const next = frame.slice(0, rows).map(line => style(line.tone ?? '0', fit(line.text, span)));
321
+ let changes = '';
322
+ for (const [index, line] of next.entries()) if (line !== lastFrame[index]) changes += `${esc}${index + 1};1H${line}${esc}K`;
323
+ lastFrame = next;
324
+ if (changes) output.write(changes);
325
+ }
326
+
327
+ async function refresh() {
328
+ if (scanning) { refreshPending = true; return; }
329
+ if (ended) return;
330
+ scanning = true;
331
+ let refreshed = false;
332
+ const screen = tab;
333
+ const inspected = detail?.project?.id;
334
+ try {
335
+ if (inspected && detail?.project?.id === inspected) {
336
+ const current = await request('inspect', { id: inspected });
337
+ if (detail?.project?.id === inspected) detail.project = current;
338
+ projects = projects.map(row => row.id === inspected ? current : row);
339
+ } else if (screen === 1) listeners = await request('ports');
340
+ else if (screen === 2) services = await request('services');
341
+ else if (screen === 3) history = await request('history');
342
+ else projects = await request('list');
343
+ refreshed = true;
344
+ } catch (error) { status = problem(error).message; }
345
+ finally {
346
+ scanning = false;
347
+ if (refreshed && status.startsWith('Scanning')) status = '';
348
+ draw();
349
+ if (refreshPending && !ended) { refreshPending = false; void refresh(); }
350
+ }
351
+ }
352
+
353
+ function switchTab(index) {
354
+ tab = (index + titles.length) % titles.length;
355
+ detail = undefined;
356
+ search = '';
357
+ viewKey = undefined;
358
+ viewIndex = 0;
359
+ void refresh();
360
+ }
361
+
362
+ async function act(action, args) {
363
+ if (operation || ended) return false;
364
+ operation = { action };
365
+ draw();
366
+ try {
367
+ const started = await request(action, args);
368
+ operation = { ...started, action };
369
+ const result = await waitOperation(started.id, item => { operation = item; draw(); }, scope.signal);
370
+ status = `${mutations[action]?.[0] ?? action} ${result.status}${result.outcomes.filter(row => row.status === 'failed').map(row => ` ${row.message}`).join('')}`;
371
+ if (result.status !== 'complete') return false;
372
+ if (action === 'adopt') detail = undefined;
373
+ return result;
374
+ } catch (error) { status = problem(error).message; return false; }
375
+ finally { operation = undefined; void refresh(); }
376
+ }
377
+
378
+ function confirm(title, lines, run) { dialog = { kind: 'confirm', title, lines, run, scroll: 0 }; }
379
+ function edit(title, text, apply) { dialog = { kind: 'input', title, text, apply }; }
380
+ function inspectProject(id) {
381
+ const project = projects.find(row => row.id === id);
382
+ if (!project) { status = 'This project is no longer visible. Refresh and try again.'; return; }
383
+ detail = { project, scroll: 0 };
384
+ void refresh();
385
+ }
386
+
387
+ function review(action, targets, args = {}) {
388
+ if (operation || !targets.length) return;
389
+ const blocked = targets.find(project => !available(project, action));
390
+ if (blocked) { status = `${mutations[action][0]} is unavailable for ${blocked.name} (${blocked.state}).`; return; }
391
+ const display = mutations[action];
392
+ confirm(`${display[0]} ${targets.length === 1 ? targets[0].name : `${targets.length} projects`}`,
393
+ [display[1], '',
394
+ ...targets.map(p => `${p.name} ${p.processCount} process(es) TCP ${ports(p.ports) || '—'}`)],
395
+ () => act(action, { ids: targets.map(p => p.id), ...args }));
396
+ }
397
+
398
+ async function reviewKill(targets) {
399
+ if (targets.length !== 1) {
400
+ status = 'Select one project to force terminate.';
401
+ return;
402
+ }
403
+ const project = await request('inspect', { id: targets[0].id });
404
+ if (ended) return;
405
+ if (!available(project, 'kill')) {
406
+ status = 'Force termination requires an owned native project with processes and no registered services.';
407
+ return;
408
+ }
409
+ const members = project.processes.map(identity);
410
+ confirm(`Force terminate ${project.name}?`, [mutations.kill[1], project.cwd, '',
411
+ ...project.processes.map(row => `PID ${row.pid} ${basename(row.image ?? '')} TCP ${ports(row.ports) || '—'}`)], () => {
412
+ edit('Type KILL to confirm force termination', '', async text => {
413
+ if (text !== 'KILL') { status = 'Force termination cancelled.'; return; }
414
+ await act('kill', { ids: [project.id], confirm: true, members });
415
+ });
416
+ });
417
+ }
418
+
419
+ async function adopt(project, after) {
420
+ const live = await request('inspect', { id: project.id });
421
+ if (ended) return;
422
+ detail = { project: live, scroll: 0 };
423
+ if (!live.candidates.length) { status = 'No current candidates to review.'; return; }
424
+ dialog = { kind: 'adopt', rows: live.candidates, approved: new Set(), index: 0, scroll: 0, project: live, after };
425
+ }
426
+
427
+ async function finishAdoption(panel, recipe) {
428
+ const members = panel.rows.filter(row => panel.approved.has(processKey(row)));
429
+ confirm(`Adopt ${members.length} reviewed process(es)`, members.map(row => `PID ${row.pid} TCP ${ports(row.ports) || '—'} ${row.image ?? ''}`), async () => {
430
+ const id = panel.project.state === 'discovered' ? undefined : panel.project.id;
431
+ const adopted = await act('adopt', { id, cwd: panel.project.cwd, confirm: true, members: members.map(identity), recipe });
432
+ if (!adopted || ended) return;
433
+ const [adoptedId] = adopted.projects;
434
+ projects = await request('list');
435
+ viewKey = adoptedId;
436
+ inspectProject(adoptedId);
437
+ if (panel.after) await panel.after(adoptedId);
438
+ });
439
+ }
440
+
441
+ async function freeze(ids) {
442
+ const targets = ids.map(id => projects.find(row => row.id === id)).filter(Boolean);
443
+ const observed = targets.find(project => project.state === 'discovered');
444
+ if (!observed) { review('freeze', targets); return; }
445
+ await adopt(observed, adoptedId => freeze(ids.map(id => id === observed.id ? adoptedId : id)));
446
+ }
447
+
448
+ async function loadRecipe(path) {
449
+ const recipe = JSON.parse(await readFile(resolve(path.replace(/^"(.*)"$/, '$1')), 'utf8'));
450
+ recipe.cwd = resolve(recipe.cwd ?? '.');
451
+ return recipe;
452
+ }
453
+
454
+ function quit() {
455
+ if (ended) return;
456
+ ended = true;
457
+ scope.abort();
458
+ close();
459
+ }
460
+
461
+ async function handle(str, key) {
462
+ const name = key?.name;
463
+ if (key?.ctrl && name === 'c') { quit(); return; }
464
+ if (dialog?.kind === 'input') {
465
+ if (name === 'escape') dialog = undefined;
466
+ else if (name === 'return') { const { text, apply } = dialog; dialog = undefined; await apply(text.trim()); }
467
+ else if (name === 'backspace') dialog.text = glyphs(dialog.text).slice(0, -1).join('');
468
+ else if (key?.ctrl && name === 'u') dialog.text = '';
469
+ else if (str && !key?.ctrl && !key?.meta && !/[\p{Cc}]/u.test(str)) dialog.text = (dialog.text + str).slice(0, 4096);
470
+ return;
471
+ }
472
+ if (name === 'q') { quit(); return; }
473
+ if (name === 'escape') {
474
+ if (dialog) dialog = undefined;
475
+ else if (detail) { detail = undefined; void refresh(); }
476
+ else if (search) search = '';
477
+ else if (tab !== 0) switchTab(0);
478
+ return;
479
+ }
480
+ const movement = ({ up: -1, down: 1, pageup: -Math.max(1, height() - 7), pagedown: Math.max(1, height() - 7) })[name];
481
+ if (dialog?.kind === 'adopt') {
482
+ if (movement) dialog.index = clamp(dialog.index + movement, 0, dialog.rows.length - 1);
483
+ if (name === 'space') {
484
+ const id = processKey(dialog.rows[dialog.index]);
485
+ if (dialog.approved.has(id)) dialog.approved.delete(id); else dialog.approved.add(id);
486
+ }
487
+ if (name === 'return' && dialog.approved.size) {
488
+ const panel = dialog;
489
+ edit('Restart recipe path (optional)', '', async path => finishAdoption(panel, path ? await loadRecipe(path) : undefined));
490
+ }
491
+ return;
492
+ }
493
+ if (dialog) {
494
+ if (dialog.kind === 'help') {
495
+ if (str === '?') { dialog = undefined; return; }
496
+ if (name === 'home' || name === 'end') dialog.scroll = name === 'home' ? 0 : Infinity;
497
+ }
498
+ if (movement) dialog.scroll = Math.max(0, (dialog.scroll ?? 0) + movement);
499
+ if (name === 'return' && dialog.kind === 'confirm') { const { run } = dialog; dialog = undefined; await run(); }
500
+ return;
501
+ }
502
+ if (str === '?') { dialog = { kind: 'help', scroll: 0 }; return; }
503
+ if (name === 'x' && operation?.id) { await request('cancel', { id: operation.id }); return; }
504
+ if (name === 'r') { void refresh(); return; }
505
+ if (name === 'tab') { switchTab(tab + (key.shift ? -1 : 1)); return; }
506
+ if (name === 'p') { switchTab(1); return; }
507
+ if (name === 'v') { switchTab(2); return; }
508
+ if (name === 'h') { switchTab(3); return; }
509
+ if (str === '/') { edit(`Search ${titles[tab].toLowerCase()}`, search, text => { search = text; detail = undefined; viewKey = undefined; viewIndex = 0; }); return; }
510
+ if (name === 'l' && !operation) {
511
+ edit('Path to JSON launch recipe', '', async path => {
512
+ if (!path) return;
513
+ const recipe = await loadRecipe(path);
514
+ confirm('Launch reviewed recipe', [recipe.cwd, `Executable: ${recipe.argv?.[0] ?? '(missing)'}`, `Readiness: ${recipe.ready ? endpoint(recipe.ready) : 'none configured'}`],
515
+ () => act('run', { recipe }));
516
+ });
517
+ return;
518
+ }
519
+ if (detail) {
520
+ if (movement) detail.scroll = Math.max(0, detail.scroll + movement);
521
+ if (name === 'space' && detail.project) detail.collapsed = !detail.collapsed;
522
+ if (name === 'return' && detail.listener?.project) inspectProject(detail.listener.project);
523
+ } else {
524
+ if (name === 'left' || name === 'right') { switchTab(tab + (name === 'left' ? -1 : 1)); return; }
525
+ if (name === 's' && tab === 0) { sort = (sort + 1) % sorts.length; return; }
526
+ if (name === 't' && tab === 0) { filter = (filter + 1) % filters.length; return; }
527
+ const rows = entries();
528
+ const row = focused(rows);
529
+ if (movement || name === 'home' || name === 'end') {
530
+ viewIndex = clamp(name === 'home' ? 0 : name === 'end' ? rows.length - 1 : viewIndex + movement, 0, rows.length - 1);
531
+ viewKey = rows[viewIndex]?.id;
532
+ return;
533
+ }
534
+ if (name === 'return' && row) {
535
+ if (row.project) inspectProject(row.id);
536
+ else detail = { ...row, scroll: 0 };
537
+ return;
538
+ }
539
+ if (name === 'space' && row?.project) {
540
+ if (selected.has(row.id)) selected.delete(row.id); else selected.add(row.id);
541
+ return;
542
+ }
543
+ }
544
+ if (operation) return;
545
+ const project = detail?.project ?? (!detail && tab === 0 ? focused(entries())?.project : undefined);
546
+ if (!project) return;
547
+ if (name === 'a') { await adopt(project); return; }
548
+ const action = shortcuts[name];
549
+ if (!action) return;
550
+ const targets = !detail && selected.size ? projects.filter(row => selected.has(row.id)) : [project];
551
+ if (!targets.length) { status = 'The selected projects are no longer visible.'; return; }
552
+ if (action === 'freeze') { await freeze(targets.map(row => row.id)); return; }
553
+ if (action === 'kill') { await reviewKill(targets); return; }
554
+ if (action === 'restore') { edit('Checkpoint ID', '', checkpoint => { if (checkpoint) review(action, targets, { checkpoint }); }); return; }
555
+ review(action, targets);
556
+ }
557
+
558
+ function keypress(str, key) {
559
+ if (ended) return;
560
+ if (handling && !(key?.ctrl && key.name === 'c') && !(operation && ['x', 'q'].includes(key?.name))) return;
561
+ const name = key?.name;
562
+ const locked = !handling && (name === 'return' || (!dialog &&
563
+ (['a', 'f', 'k'].includes(name) || (operation?.id && name === 'x'))));
564
+ if (locked) handling = true;
565
+ void handle(str, key).catch(error => { status = problem(error).message; }).finally(() => {
566
+ if (locked) handling = false;
567
+ draw();
568
+ });
569
+ }
570
+ function resize() { lastFrame = []; output.write(`${esc}2J`); draw(); }
571
+ emitKeypressEvents(input);
572
+ input.setRawMode(true);
573
+ input.on('keypress', keypress);
574
+ input.once('end', quit);
575
+ output.on('resize', resize);
576
+ process.once('SIGINT', quit);
577
+ process.once('SIGTERM', quit);
578
+ try {
579
+ output.write(`${esc}?1049h${esc}?25l${esc}2J`);
580
+ input.resume();
581
+ timer = setInterval(() => void refresh(), 2500);
582
+ draw();
583
+ void refresh();
584
+ await done;
585
+ } finally {
586
+ clearInterval(timer);
587
+ input.off('keypress', keypress);
588
+ input.off('end', quit);
589
+ input.setRawMode(wasRaw);
590
+ input.pause();
591
+ output.off('resize', resize);
592
+ process.off('SIGINT', quit);
593
+ process.off('SIGTERM', quit);
594
+ output.write(`${esc}0m${esc}?25h${esc}?1049l${operation?.id ? `Operation ${operation.id} continues in the daemon.\n` : ''}`);
595
+ }
596
+ }