ima2-gen 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +14 -3
  2. package/bin/commands/service.js +450 -0
  3. package/bin/commands/stop.js +129 -0
  4. package/bin/ima2.js +15 -1
  5. package/bin/lib/serviceTemplates.js +87 -0
  6. package/docs/API.md +1 -0
  7. package/docs/migration/runtime-test-inventory.md +3 -1
  8. package/lib/processControl.js +138 -0
  9. package/lib/runtimeContext.js +1 -0
  10. package/package.json +2 -2
  11. package/routes/admin.js +51 -0
  12. package/routes/edit.js +21 -0
  13. package/routes/index.js +2 -0
  14. package/server.js +12 -3
  15. package/ui/dist/.vite/manifest.json +32 -32
  16. package/ui/dist/assets/{AgentWorkspace-fcIXiWV6.js → AgentWorkspace-DAOw-Q1x.js} +1 -1
  17. package/ui/dist/assets/AssetGenWorkspace-Bhi2OrfW.js +2 -0
  18. package/ui/dist/assets/AssetsWorkspace-D-pcyPvz.js +1 -0
  19. package/ui/dist/assets/{CardNewsWorkspace-ltdjG40R.js → CardNewsWorkspace-BETskmU7.js} +1 -1
  20. package/ui/dist/assets/{GenerationRequestLogPanel-B68EUfUg.js → GenerationRequestLogPanel-sr-wSiOe.js} +1 -1
  21. package/ui/dist/assets/{HomeWorkspace-BBOG_aNm.js → HomeWorkspace-vjN-A1AR.js} +1 -1
  22. package/ui/dist/assets/{KeyingPanel-Bv0n_bhk.js → KeyingPanel-D7iozqcS.js} +1 -1
  23. package/ui/dist/assets/{NodeCanvas-VwdctvAW.js → NodeCanvas-Bu6q2z3V.js} +1 -1
  24. package/ui/dist/assets/{PromptBuilderPanel-BBMpF_3Q.js → PromptBuilderPanel-DklSQCS_.js} +2 -2
  25. package/ui/dist/assets/{PromptImportDialog-D4QYbRfH.js → PromptImportDialog-CZwFZDvz.js} +2 -2
  26. package/ui/dist/assets/{PromptImportDiscoverySection-Bx7BeTFh.js → PromptImportDiscoverySection-B5Lyhl9X.js} +1 -1
  27. package/ui/dist/assets/{PromptImportFolderSection-Bwf60URx.js → PromptImportFolderSection-DmMFtYmn.js} +1 -1
  28. package/ui/dist/assets/{PromptLibraryPanel-mNG2wZkQ.js → PromptLibraryPanel-BHr73ns_.js} +2 -2
  29. package/ui/dist/assets/SettingsWorkspace-DrQ44R-B.js +1 -0
  30. package/ui/dist/assets/{SpriteRecipeWorkspace-Da9qTQX0.js → SpriteRecipeWorkspace-ChboDVCj.js} +1 -1
  31. package/ui/dist/assets/index-C4IY6hxw.css +1 -0
  32. package/ui/dist/assets/index-CVVv0v-b.js +30 -0
  33. package/ui/dist/assets/index-D1SD3LcV.js +5 -0
  34. package/ui/dist/assets/{pptxgen.es-BE4CSd5F.js → pptxgen.es-DIQkG3ve.js} +1 -1
  35. package/ui/dist/assets/{useAgentDialogFocus-R64NV-ZZ.js → useAgentDialogFocus-CqjQmoEH.js} +1 -1
  36. package/ui/dist/index.html +15 -3
  37. package/ui/dist/assets/AssetGenWorkspace-CqXyN9at.js +0 -2
  38. package/ui/dist/assets/AssetsWorkspace-xwHFVOb7.js +0 -1
  39. package/ui/dist/assets/SettingsWorkspace-DA5tIa0Q.js +0 -1
  40. package/ui/dist/assets/index-Bv1Yrz8W.js +0 -5
  41. package/ui/dist/assets/index-C0nfanMr.css +0 -1
  42. package/ui/dist/assets/index-CxEuSZMC.js +0 -30
package/README.md CHANGED
@@ -16,9 +16,9 @@
16
16
 
17
17
  `ima2-gen` is a local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.
18
18
 
19
- Install globally and generate images and videos from eight core lanes: OpenAI OAuth/API, Grok OAuth/API, Antigravity CLI, Gemini API, AtlasCloud, and MiniMax. Runway and Higgsfield remain separate MCP-backed integrations. Iterate with history, references, node branches, multimode batches, and Canvas Mode cleanup.
19
+ Install globally and generate images and videos from eight core lanes: OpenAI OAuth/API, Grok OAuth/API, Antigravity CLI, Gemini API, AtlasCloud, and MiniMax. Runway and Higgsfield remain separate MCP-backed integrations. Iterate with history, references, node branches, multimode batches, and Canvas Mode cleanup — now with one-click GPT background transparency (server-verified real alpha), annotation hover feedback, and a full light/dark/system theme.
20
20
 
21
- ![ima2-gen video playback with gallery sidebar showing generated images and videos.](assets/screenshots/classic-generate-light.png)
21
+ ![ima2-gen classic workspace in light mode with a transparent-background result on the canvas.](assets/screenshots/classic-generate-lightmode.png)
22
22
 
23
23
  ## Quick Start
24
24
 
@@ -91,6 +91,7 @@ Video generation requires Grok OAuth (option 2 or 3). Run `ima2 grok login` sepa
91
91
  ### Updating
92
92
 
93
93
  Stop the running server with Ctrl+C, then:
94
+ (or from another terminal: `ima2 stop`)
94
95
 
95
96
  ```bash
96
97
  npm install -g ima2-gen@latest
@@ -105,7 +106,8 @@ Ctrl+C now performs a clean shutdown — closing the database, stopping child pr
105
106
  - **Multimode batches**: launch several Classic outputs from one prompt, watch slot-by-slot progress, and continue from the best result.
106
107
  - **Video generation**: create short videos from text, a single image, or multiple reference images via Grok video models. SSE streaming shows planning → submitted → progress % → done. Video frame copy buttons (First/Mid/Last) let you extract and copy keyframes from generated videos.
107
108
  - **Storyboard mode**: toggle storyboard mode in the composer to maintain character and scene continuity across sequential frames. Works with both image and video generation — image keyframes are composed for video production, and video clips inherit character/environment lock rules.
108
- - **Canvas Mode**: zoom, pan, annotate, erase, clean backgrounds, keep transparent previews, and export either alpha or matte-backed versions.
109
+ - **Canvas Mode**: zoom, pan, annotate (with hover highlighting), erase, clean backgrounds, keep transparent previews, and export either alpha or matte-backed versions. A one-click **GPT transparency** button sends the current image through the i2i edit lane and reports honestly whether the result carries real pixel alpha — verified on the server, never trusted from provider metadata.
110
+ - **Light & dark themes**: a token-based light mode with tinted neutrals and AA contrast, switchable between light / dark / system in Settings, with no flash on load.
109
111
  - **Local gallery**: keep generated assets on your machine with session-aware history. By default the gallery shows the current session and an All Images toggle reveals the full history; the default scope is sticky across sessions. Each image records its generation time and reasoning effort in the result metadata, so they persist across reloads.
110
112
  - **Reference images**: drag, drop, paste, and attach up to 5 references (images) or up to 7 references (video); large images are compressed before upload.
111
113
  - **Prompt library imports**: import local prompt packs, GitHub folders, and curated GPT-image prompt hints into the built-in prompt library.
@@ -212,9 +214,13 @@ Use Canvas Mode when a generated image is close but needs targeted cleanup befor
212
214
  - Pick background-cleanup seeds, preview the mask, and save the cleanup as a canvas version.
213
215
  - Detect transparent images and show a checkerboard preview; export with preserved alpha or with a chosen matte color.
214
216
  - Saved canvas versions stay hidden from Gallery and HistoryStrip, but Canvas Mode can reuse them and attach a canvas version as the next reference.
217
+ - Hover any annotation with the select tool to see a live outline and a move cursor before you click.
218
+ - Press the **GPT transparency** button in the canvas toolbar to remove the background through the OAuth i2i lane in one click. The server decodes the returned bytes and reports `alphaVerified` — the toast tells you whether real transparency actually landed, instead of assuming.
215
219
 
216
220
  ![Canvas Mode with zoom controls, annotation marks, a sticky note, and the canvas toolbar.](assets/screenshots/canvas-mode-cleanup.png)
217
221
 
222
+ ![Canvas Mode in light theme with the one-click GPT transparency flow and a transparent checkerboard result.](assets/screenshots/canvas-mode-light-transparency.png)
223
+
218
224
  ### Prompt Library And Imports
219
225
 
220
226
  The prompt library can now be filled from local files, GitHub folders, curated sources, and GPT-image hint packs. Imported prompts are indexed locally so search and ranking work without re-importing the same source every session.
@@ -230,9 +236,12 @@ be treated as a stable public feature yet.
230
236
  ### Settings
231
237
 
232
238
  The settings workspace keeps account, model, appearance, and language controls away from the generation sidebar.
239
+ Appearance now includes a light / dark / system theme toggle; the light palette uses tinted neutrals with AA contrast and applies before first paint.
233
240
 
234
241
  ![Settings workspace with account navigation and generation model controls.](assets/screenshots/settings-workspace.png)
235
242
 
243
+ ![Settings appearance section with the light, dark, and system theme toggle in light mode.](assets/screenshots/settings-theme-toggle.png)
244
+
236
245
  ## CLI Commands
237
246
 
238
247
  ### Server
@@ -240,6 +249,8 @@ The settings workspace keeps account, model, appearance, and language controls a
240
249
  | Command | Description |
241
250
  |---|---|
242
251
  | `ima2 serve [--dev]` | Start the local web server; `--dev` enables verbose server diagnostics |
252
+ | `ima2 stop [--force]` | Stop the running server safely — graceful admin-API stop first, then SIGTERM/SIGKILL; verifies the advertised pid against `/api/health` so a recycled pid is never killed |
253
+ | `ima2 service <sub>` | Background service: `install`/`uninstall`/`start`/`stop`/`restart`/`status`/`logs`/`repair` — launchd on macOS, systemd user unit on Linux, auto-start on login with crash restart |
243
254
  | `ima2 setup` | Reconfigure saved auth |
244
255
  | `ima2 status` | Show config and OAuth status |
245
256
  | `ima2 doctor` | Diagnose Node, package, config, and auth |
@@ -0,0 +1,450 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { LAUNCHD_LABEL, SYSTEMD_UNIT, launchctlOutputIndicatesFailure, renderLaunchdPlist, renderSystemdUnit, serviceStateStale, } from "../lib/serviceTemplates.js";
7
+ import { corroborateByStartTime, escalateKill, gracefulStop, isProcessAlive, verifyServerIdentity, waitForExit, } from "../../lib/processControl.js";
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const ROOT = join(__dirname, "..", "..");
10
+ function configDir() {
11
+ return process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2");
12
+ }
13
+ function stateFile() {
14
+ return join(configDir(), "service-state.json");
15
+ }
16
+ function logDir() {
17
+ return join(configDir(), "logs");
18
+ }
19
+ function plistPath() {
20
+ return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
21
+ }
22
+ function unitPath() {
23
+ return join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
24
+ }
25
+ function advertisePath() {
26
+ return process.env.IMA2_ADVERTISE_FILE || join(configDir(), "server.json");
27
+ }
28
+ function readState() {
29
+ try {
30
+ return JSON.parse(readFileSync(stateFile(), "utf-8"));
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function currentPaths() {
37
+ return { nodePath: process.execPath, serverJs: join(ROOT, "server.js") };
38
+ }
39
+ function renderInput() {
40
+ const cur = currentPaths();
41
+ return {
42
+ nodePath: cur.nodePath,
43
+ serverJs: cur.serverJs,
44
+ rootDir: ROOT,
45
+ pathEnv: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
46
+ logDir: logDir(),
47
+ configDir: process.env.IMA2_CONFIG_DIR,
48
+ };
49
+ }
50
+ function run(cmd, args) {
51
+ try {
52
+ const stdout = execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
53
+ return { ok: true, stdout, stderr: "" };
54
+ }
55
+ catch (e) {
56
+ const err = e;
57
+ return { ok: false, stdout: err.stdout ?? "", stderr: err.stderr ?? err.message ?? "" };
58
+ }
59
+ }
60
+ function guiDomain() {
61
+ return `gui/${process.getuid?.() ?? 501}`;
62
+ }
63
+ function writeState() {
64
+ const cur = currentPaths();
65
+ const state = {
66
+ version: 1,
67
+ platform: process.platform,
68
+ nodePath: cur.nodePath,
69
+ serverJs: cur.serverJs,
70
+ configDir: configDir(),
71
+ installedAt: Date.now(),
72
+ };
73
+ mkdirSync(dirname(stateFile()), { recursive: true });
74
+ writeFileSync(stateFile(), JSON.stringify(state, null, 2));
75
+ }
76
+ function readAdvertise() {
77
+ try {
78
+ return JSON.parse(readFileSync(advertisePath(), "utf-8"));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ async function waitForHealth(timeoutMs) {
85
+ const deadline = Date.now() + timeoutMs;
86
+ while (Date.now() < deadline) {
87
+ const entry = readAdvertise();
88
+ if (entry?.url) {
89
+ try {
90
+ const r = await fetch(`${String(entry.url).replace(/\/$/, "")}/api/health`, { headers: { connection: "close" } });
91
+ if (r.ok)
92
+ return { ok: true, entry };
93
+ }
94
+ catch { /* keep polling */ }
95
+ }
96
+ await new Promise((r) => setTimeout(r, 500));
97
+ }
98
+ return { ok: false, entry: readAdvertise() };
99
+ }
100
+ function reportProviderLiveness(entry) {
101
+ if (!entry)
102
+ return;
103
+ const oauth = entry.oauth;
104
+ const grok = entry.grok;
105
+ if (oauth?.status && oauth.status !== "ready" && oauth.status !== "disabled") {
106
+ console.log(` Warning: GPT OAuth proxy status is "${oauth.status}" — check 'ima2 service logs'.`);
107
+ }
108
+ // grok.live only exists in the advertise payload, not /api/health (audit note).
109
+ if (grok && grok.live === false) {
110
+ console.log(" Warning: Grok proxy is not live under the service environment.");
111
+ console.log(" If Grok worked in a terminal, the service PATH may be missing its binary.");
112
+ }
113
+ }
114
+ // ── macOS (launchd) ──
115
+ async function macInstall() {
116
+ mkdirSync(logDir(), { recursive: true });
117
+ mkdirSync(dirname(plistPath()), { recursive: true });
118
+ writeFileSync(plistPath(), renderLaunchdPlist(renderInput()));
119
+ const boot = await macBootstrapWithRetry();
120
+ if (!boot.ok || launchctlOutputIndicatesFailure(boot.stderr)) {
121
+ const legacy = run("/bin/launchctl", ["load", "-w", plistPath()]);
122
+ if (!legacy.ok || launchctlOutputIndicatesFailure(legacy.stderr)) {
123
+ console.error(` launchctl could not load the service: ${boot.stderr || legacy.stderr || "unknown"}`);
124
+ return false;
125
+ }
126
+ }
127
+ writeState();
128
+ return true;
129
+ }
130
+ async function macBootout() {
131
+ run("/bin/launchctl", ["bootout", `${guiDomain()}/${LAUNCHD_LABEL}`]);
132
+ // bootout is asynchronous: bootstrapping again while the old job is still
133
+ // draining fails with "Bootstrap failed: 5: Input/output error" (hit live
134
+ // during 040 verification — restart left the service unregistered). Wait for
135
+ // the registration to actually disappear before letting a start proceed.
136
+ const deadline = Date.now() + 5000;
137
+ while (Date.now() < deadline && macRegistered()) {
138
+ await new Promise((r) => setTimeout(r, 200));
139
+ }
140
+ // Honest outcome: a job still registered after the drain window is a FAILED
141
+ // bootout, not a success to report (audit P2).
142
+ return !macRegistered();
143
+ }
144
+ async function macBootstrapWithRetry() {
145
+ let last = { ok: false, stdout: "", stderr: "never attempted" };
146
+ for (let attempt = 0; attempt < 3; attempt++) {
147
+ last = run("/bin/launchctl", ["bootstrap", guiDomain(), plistPath()]);
148
+ const failed = !last.ok || launchctlOutputIndicatesFailure(last.stderr);
149
+ if (!failed && macRegistered())
150
+ return { ...last, ok: true };
151
+ await new Promise((r) => setTimeout(r, 1000));
152
+ if (macRegistered())
153
+ return { ...last, ok: true };
154
+ }
155
+ return last;
156
+ }
157
+ function macRegistered() {
158
+ const r = run("/bin/launchctl", ["print", `${guiDomain()}/${LAUNCHD_LABEL}`]);
159
+ return r.ok;
160
+ }
161
+ // ── Linux (systemd user unit) ──
162
+ function linuxInstall() {
163
+ mkdirSync(logDir(), { recursive: true });
164
+ mkdirSync(dirname(unitPath()), { recursive: true });
165
+ writeFileSync(unitPath(), renderSystemdUnit(renderInput()));
166
+ const reload = run("systemctl", ["--user", "daemon-reload"]);
167
+ if (!reload.ok) {
168
+ console.error(` systemctl daemon-reload failed: ${reload.stderr}`);
169
+ return false;
170
+ }
171
+ const enable = run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
172
+ if (!enable.ok) {
173
+ console.error(` systemctl enable --now failed: ${enable.stderr}`);
174
+ return false;
175
+ }
176
+ writeState();
177
+ return true;
178
+ }
179
+ // ── shared flows ──
180
+ /**
181
+ * Stop whatever server the advertise file names — same doctrine as
182
+ * `ima2 stop` (audit blocker 2): graceful admin-API first, identity-verified
183
+ * signals second, and NEVER a signal to a pid we cannot corroborate.
184
+ */
185
+ async function stopLiveServer() {
186
+ const entry = readAdvertise();
187
+ if (!entry?.pid)
188
+ return;
189
+ const pid = Number(entry.pid);
190
+ if (!isProcessAlive(pid)) {
191
+ try {
192
+ unlinkSync(advertisePath());
193
+ }
194
+ catch { /* gone already */ }
195
+ return;
196
+ }
197
+ const identity = await verifyServerIdentity(entry);
198
+ if (identity === "mismatch") {
199
+ console.log(` Note: a different server answers where pid ${pid} was advertised; leaving it alone.`);
200
+ try {
201
+ unlinkSync(advertisePath());
202
+ }
203
+ catch { /* stale */ }
204
+ return;
205
+ }
206
+ if (identity === "unreachable") {
207
+ const corroboration = corroborateByStartTime(pid, Number(entry.startedAt) || undefined);
208
+ if (corroboration !== "corroborated") {
209
+ console.log(` Note: pid ${pid} could not be identified as the ima2 server; not signalling it.`);
210
+ if (corroboration === "recycled") {
211
+ try {
212
+ unlinkSync(advertisePath());
213
+ }
214
+ catch { /* stale */ }
215
+ }
216
+ return;
217
+ }
218
+ }
219
+ if (identity === "match" && (await gracefulStop(entry)) && (await waitForExit(pid, 8000))) {
220
+ try {
221
+ unlinkSync(advertisePath());
222
+ }
223
+ catch { /* server removed it */ }
224
+ return;
225
+ }
226
+ await escalateKill(pid);
227
+ try {
228
+ unlinkSync(advertisePath());
229
+ }
230
+ catch { /* gone */ }
231
+ }
232
+ async function install() {
233
+ if (process.platform === "win32") {
234
+ console.log("\n Windows service management is not built in yet.");
235
+ console.log(" Register manually with Task Scheduler: run at logon,");
236
+ console.log(` program: ${currentPaths().nodePath}`);
237
+ console.log(` arguments: ${currentPaths().serverJs}\n`);
238
+ return;
239
+ }
240
+ // A manually-started server would fight the KeepAlive service over the port.
241
+ await stopLiveServer();
242
+ const ok = process.platform === "darwin" ? await macInstall() : linuxInstall();
243
+ if (!ok) {
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+ console.log(`\n Service installed (${process.platform === "darwin" ? "launchd" : "systemd user unit"}).`);
248
+ const health = await waitForHealth(12_000);
249
+ if (health.ok) {
250
+ console.log(` Server is up at ${health.entry?.url} (pid ${health.entry?.pid}).`);
251
+ reportProviderLiveness(health.entry);
252
+ }
253
+ else {
254
+ console.log(" Service registered but the server has not answered /api/health yet.");
255
+ console.log(" Inspect: ima2 service logs\n");
256
+ process.exitCode = 1;
257
+ return;
258
+ }
259
+ console.log("");
260
+ }
261
+ async function uninstall() {
262
+ if (process.platform === "win32") {
263
+ console.log("\n Windows service management is not built in yet — nothing to uninstall.\n");
264
+ return;
265
+ }
266
+ if (process.platform === "darwin") {
267
+ const drained = await macBootout();
268
+ if (!drained) {
269
+ console.error(" Warning: launchctl still reports the job registered; artifacts removed anyway.");
270
+ }
271
+ try {
272
+ unlinkSync(plistPath());
273
+ }
274
+ catch { /* absent is fine */ }
275
+ }
276
+ else if (process.platform === "linux") {
277
+ run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
278
+ try {
279
+ unlinkSync(unitPath());
280
+ }
281
+ catch { /* absent is fine */ }
282
+ run("systemctl", ["--user", "daemon-reload"]);
283
+ }
284
+ try {
285
+ unlinkSync(stateFile());
286
+ }
287
+ catch { /* absent is fine */ }
288
+ await stopLiveServer();
289
+ console.log("\n Service uninstalled. Server stopped.\n");
290
+ }
291
+ async function start() {
292
+ if (process.platform === "darwin") {
293
+ if (!existsSync(plistPath())) {
294
+ console.error("\n No service installed. Run 'ima2 service install' first.\n");
295
+ process.exitCode = 1;
296
+ return;
297
+ }
298
+ const boot = await macBootstrapWithRetry();
299
+ if (!boot.ok && !macRegistered()) {
300
+ console.error(`\n launchctl bootstrap failed: ${boot.stderr}\n`);
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ }
305
+ else if (process.platform === "linux") {
306
+ run("systemctl", ["--user", "start", SYSTEMD_UNIT]);
307
+ }
308
+ const health = await waitForHealth(12_000);
309
+ console.log(health.ok ? `\n Service started — ${health.entry?.url}\n` : "\n Start issued; server not answering yet. See 'ima2 service logs'.\n");
310
+ }
311
+ async function stopSvc() {
312
+ // bootout (not kill): with KeepAlive, killing the pid just respawns it.
313
+ if (process.platform === "win32") {
314
+ console.log("\n Windows service management is not built in yet.\n");
315
+ process.exitCode = 1;
316
+ return;
317
+ }
318
+ if (process.platform === "darwin") {
319
+ const drained = await macBootout();
320
+ if (!drained) {
321
+ console.error("\n launchctl bootout did not take — the job is still registered.");
322
+ console.error(` Inspect: launchctl print gui/$UID/${LAUNCHD_LABEL}\n`);
323
+ process.exitCode = 1;
324
+ return;
325
+ }
326
+ }
327
+ else if (process.platform === "linux")
328
+ run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
329
+ await stopLiveServer();
330
+ console.log("\n Service stopped (registration removed until 'ima2 service start').\n");
331
+ }
332
+ async function status() {
333
+ if (process.platform === "win32") {
334
+ console.log("\n Windows service management is not built in yet; no status to report.\n");
335
+ return;
336
+ }
337
+ const state = readState();
338
+ const installedArtifact = process.platform === "darwin" ? plistPath() : unitPath();
339
+ const artifactExists = existsSync(installedArtifact);
340
+ console.log("\n ima2 service status\n");
341
+ console.log(` Artifact: ${installedArtifact} ${artifactExists ? "✓" : "✗ (not installed)"}`);
342
+ if (process.platform === "darwin") {
343
+ console.log(` launchd registration: ${macRegistered() ? "✓ loaded" : "✗ not loaded"}`);
344
+ }
345
+ else if (process.platform === "linux") {
346
+ const st = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
347
+ console.log(` systemd unit: ${st.stdout.trim() || st.stderr.trim()}`);
348
+ const linger = run("loginctl", ["show-user", process.env.USER || "", "--property=Linger"]);
349
+ if (linger.ok && linger.stdout.includes("Linger=no")) {
350
+ console.log(" Note: lingering is off — the service stops when you log out.");
351
+ console.log(" Enable with: loginctl enable-linger $USER");
352
+ }
353
+ }
354
+ if (state) {
355
+ const stale = serviceStateStale(state, { ...currentPaths(), configDir: configDir() });
356
+ console.log(` Installed: ${new Date(state.installedAt).toLocaleString()} (node ${state.nodePath})`);
357
+ for (const issue of stale)
358
+ console.log(` Stale: ${issue} — run 'ima2 service repair'`);
359
+ }
360
+ else {
361
+ console.log(" State file: none");
362
+ }
363
+ const entry = readAdvertise();
364
+ if (entry?.pid && isProcessAlive(Number(entry.pid))) {
365
+ console.log(` Live server: pid ${entry.pid} at ${entry.url}`);
366
+ reportProviderLiveness(entry);
367
+ }
368
+ else {
369
+ console.log(" Live server: none");
370
+ }
371
+ console.log("");
372
+ }
373
+ function logs(args) {
374
+ const nFlag = args.indexOf("-n");
375
+ const n = Math.max(1, (nFlag >= 0 ? Number(args[nFlag + 1]) : NaN) || 50);
376
+ if (process.platform === "linux") {
377
+ const r = run("journalctl", ["--user", "-u", SYSTEMD_UNIT, "-n", String(n), "--no-pager"]);
378
+ console.log(r.stdout || r.stderr);
379
+ return;
380
+ }
381
+ for (const f of ["service.out.log", "service.err.log"]) {
382
+ const p = join(logDir(), f);
383
+ if (!existsSync(p))
384
+ continue;
385
+ console.log(`\n── ${p} (last ${n} lines) ──`);
386
+ const lines = readFileSync(p, "utf-8").split("\n");
387
+ console.log(lines.slice(-n).join("\n"));
388
+ }
389
+ console.log("");
390
+ }
391
+ async function repair() {
392
+ if (!readState() && !existsSync(plistPath()) && !existsSync(unitPath())) {
393
+ console.error("\n Nothing to repair — no service installed.\n");
394
+ process.exitCode = 1;
395
+ return;
396
+ }
397
+ console.log("\n Re-rendering service artifacts for the current paths...");
398
+ if (process.platform === "darwin")
399
+ await macBootout();
400
+ else if (process.platform === "linux")
401
+ run("systemctl", ["--user", "stop", SYSTEMD_UNIT]);
402
+ await install();
403
+ }
404
+ const HELP = `
405
+ ima2 service — run the server as a background service
406
+
407
+ Subcommands:
408
+ install Register (launchd/systemd), start now + on login, auto-restart
409
+ uninstall Deregister, remove artifacts, stop the server
410
+ start Re-register a stopped service
411
+ stop Stop and deregister until 'start' (KeepAlive-safe)
412
+ restart stop + start
413
+ status Artifact / registration / state / live-server report
414
+ logs [-n N] Show service logs (default 50 lines)
415
+ repair Re-render artifacts after node/npm path moves
416
+ `;
417
+ export async function service(args = []) {
418
+ const sub = args[0];
419
+ switch (sub) {
420
+ case "install":
421
+ await install();
422
+ break;
423
+ case "uninstall":
424
+ await uninstall();
425
+ break;
426
+ case "start":
427
+ await start();
428
+ break;
429
+ case "stop":
430
+ await stopSvc();
431
+ break;
432
+ case "restart":
433
+ await stopSvc();
434
+ await start();
435
+ break;
436
+ case "status":
437
+ await status();
438
+ break;
439
+ case "logs":
440
+ logs(args.slice(1));
441
+ break;
442
+ case "repair":
443
+ await repair();
444
+ break;
445
+ default:
446
+ console.log(HELP);
447
+ if (sub && sub !== "-h" && sub !== "--help")
448
+ process.exitCode = 1;
449
+ }
450
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { corroborateByStartTime, escalateKill, gracefulStop, isProcessAlive, verifyServerIdentity, waitForExit, } from "../../lib/processControl.js";
5
+ function advertisePath() {
6
+ return (process.env.IMA2_ADVERTISE_FILE ||
7
+ join(process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2"), "server.json"));
8
+ }
9
+ function readAdvertise(path) {
10
+ if (!existsSync(path))
11
+ return null;
12
+ try {
13
+ return JSON.parse(readFileSync(path, "utf-8"));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function cleanupAdvertise(path, pid) {
20
+ try {
21
+ const cur = readAdvertise(path);
22
+ if (cur && (pid === undefined || cur.pid === pid))
23
+ unlinkSync(path);
24
+ }
25
+ catch {
26
+ /* best effort */
27
+ }
28
+ }
29
+ /**
30
+ * `ima2 stop [--force]` — stop the running ima2 server safely.
31
+ *
32
+ * Sequence: advertise file → pid identity verification (never kill a recycled
33
+ * pid) → graceful admin-API stop → SIGTERM → SIGKILL escalation → stale-file
34
+ * cleanup. Idempotent: "not running" exits 0.
35
+ */
36
+ export async function stop(args = []) {
37
+ const force = args.includes("--force");
38
+ const path = advertisePath();
39
+ const entry = readAdvertise(path);
40
+ if (!entry || !entry.pid) {
41
+ if (entry === null && existsSync(path)) {
42
+ cleanupAdvertise(path, undefined);
43
+ console.log("\n Removed unreadable advertise file. No server to stop.\n");
44
+ return;
45
+ }
46
+ console.log("\n ima2 server is not running.\n");
47
+ return;
48
+ }
49
+ const pid = Number(entry.pid);
50
+ if (!isProcessAlive(pid)) {
51
+ cleanupAdvertise(path, pid);
52
+ console.log(`\n ima2 server (pid ${pid}) is not running. Cleaned stale advertise file.\n`);
53
+ return;
54
+ }
55
+ if (process.platform === "win32") {
56
+ console.log("\n 'ima2 stop' is not supported on Windows yet (SIGTERM would orphan");
57
+ console.log(" the provider proxies). Stop the server from its own terminal (Ctrl+C)");
58
+ console.log(` or: taskkill /PID ${pid} /T\n`);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+ // Service-managed? KeepAlive will resurrect a plain kill — refuse without --force
63
+ // (ownership refusal, mirroring opencodex's 409 semantics in spirit).
64
+ const stateFile = join(process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2"), "service-state.json");
65
+ if (existsSync(stateFile)) {
66
+ if (!force) {
67
+ console.log("\n ima2 is installed as a background service: KeepAlive would restart");
68
+ console.log(" the server immediately after this stop, so it would be a lie.");
69
+ console.log(" Use 'ima2 service stop' — or 'ima2 stop --force' to kill it anyway.\n");
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ console.log("\n --force: stopping a service-managed server; KeepAlive may restart it.\n");
74
+ }
75
+ const identity = await verifyServerIdentity(entry);
76
+ if (identity === "mismatch") {
77
+ cleanupAdvertise(path, pid);
78
+ console.log(`\n A different server answers where pid ${pid} was advertised.`);
79
+ console.log(" Refusing to kill a process the advertise file cannot vouch for.");
80
+ console.log(" Cleaned the stale advertise file; stop the other server from its own CLI.\n");
81
+ return;
82
+ }
83
+ if (identity === "unreachable") {
84
+ // HTTP says nothing — corroborate with the process start time before ANY
85
+ // signal. A recycled pid is provably younger than the advertised boot;
86
+ // when we cannot tell, we refuse rather than guess (audit blocker 1).
87
+ const corroboration = corroborateByStartTime(pid, Number(entry.startedAt) || undefined);
88
+ if (corroboration !== "corroborated") {
89
+ console.log(`\n pid ${pid} is alive but the server is unreachable, and its start time`);
90
+ console.log(corroboration === "recycled"
91
+ ? " shows it is NOT the advertised server (the pid was recycled)."
92
+ : " could not be corroborated against the advertise file.");
93
+ console.log(" Refusing to send signals to a process that may not be ours.");
94
+ if (corroboration === "recycled")
95
+ cleanupAdvertise(path, pid);
96
+ else
97
+ console.log(` If you are sure, stop it manually: kill ${pid}`);
98
+ console.log("");
99
+ process.exitCode = corroboration === "recycled" ? 0 : 1;
100
+ return;
101
+ }
102
+ }
103
+ if (!force && identity === "match") {
104
+ const ok = await gracefulStop(entry);
105
+ if (ok && (await waitForExit(pid, 8000))) {
106
+ cleanupAdvertise(path, pid);
107
+ console.log(`\n Stopped ima2 server (pid ${pid}) gracefully.\n`);
108
+ return;
109
+ }
110
+ }
111
+ const outcome = await escalateKill(pid);
112
+ switch (outcome) {
113
+ case "already-dead":
114
+ console.log(`\n ima2 server (pid ${pid}) had already exited.\n`);
115
+ break;
116
+ case "term":
117
+ console.log(`\n Stopped ima2 server (pid ${pid}) with SIGTERM.\n`);
118
+ break;
119
+ case "kill":
120
+ console.log(`\n Force-killed ima2 server (pid ${pid}) with SIGKILL.`);
121
+ console.log(" Note: helper proxies may have been left behind; they exit on their own.\n");
122
+ break;
123
+ case "failed":
124
+ console.error(`\n Could not stop pid ${pid}. Try: kill -9 ${pid}\n`);
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ cleanupAdvertise(path, pid);
129
+ }
package/bin/ima2.js CHANGED
@@ -341,6 +341,8 @@ function showHelp() {
341
341
  capabilities Agent capability metadata (ima2 capabilities --help)
342
342
  tools Machine tool contracts for agents (ima2 tools --help)
343
343
  ping Ping running server / check health
344
+ stop Stop the running server safely (graceful, then signals)
345
+ service Background service management (install/status/... ; -h)
344
346
 
345
347
  Agent skills (SKILL.md + references/):
346
348
  skill ls List packaged skills (ima2, front, uiux)
@@ -400,7 +402,7 @@ if (args.includes("-v") || args.includes("--version")) {
400
402
  console.log(pkg.version);
401
403
  exitFlushed(0);
402
404
  }
403
- const helpOwningCommands = ["doctor", "gen", "video", "edit", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs"];
405
+ const helpOwningCommands = ["doctor", "gen", "video", "edit", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs", "service"];
404
406
  if (!command) {
405
407
  showHelp();
406
408
  exitFlushed(1);
@@ -413,6 +415,18 @@ switch (command) {
413
415
  case "serve":
414
416
  serve(args.slice(1));
415
417
  break;
418
+ case "stop": {
419
+ const { stop } = await import("./commands/stop.js");
420
+ await stop(args.slice(1));
421
+ exitFlushed(Number(process.exitCode ?? 0));
422
+ break;
423
+ }
424
+ case "service": {
425
+ const { service } = await import("./commands/service.js");
426
+ await service(args.slice(1));
427
+ exitFlushed(Number(process.exitCode ?? 0));
428
+ break;
429
+ }
416
430
  case "setup":
417
431
  case "login":
418
432
  setup().then(() => console.log(" Done. Run 'ima2 serve' to start.")).catch((e) => {