oomi-ai 0.2.28 → 0.2.38

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.
@@ -1,152 +1,409 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
-
6
- function resolveNpmCommand() {
7
- return process.platform === 'win32' ? 'npm.cmd' : 'npm';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+
5
+ import { resolveOpenclawPersonasDir } from './openclawPaths.js';
6
+
7
+ function resolveNpmCommand() {
8
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
9
+ }
10
+
11
+ function ensureDir(dirPath) {
12
+ fs.mkdirSync(dirPath, { recursive: true });
13
+ }
14
+
15
+ function quoteWindowsCommandPart(value) {
16
+ const text = String(value);
17
+ if (!/[\s"]/u.test(text)) {
18
+ return text;
19
+ }
20
+ return `"${text.replace(/"/g, '""')}"`;
21
+ }
22
+
23
+ function buildWindowsCommandLine(command, args) {
24
+ const quotedCommand = quoteWindowsCommandPart(command);
25
+ const quotedArgs = args.map((value) => quoteWindowsCommandPart(value)).join(' ');
26
+ return `${quotedCommand}${quotedArgs ? ` ${quotedArgs}` : ''}`;
27
+ }
28
+
29
+ function normalizePositiveInteger(value) {
30
+ const parsed = Number(value);
31
+ if (!Number.isFinite(parsed) || parsed <= 0) {
32
+ return null;
33
+ }
34
+ return Math.floor(parsed);
8
35
  }
9
36
 
10
- function ensureDir(dirPath) {
11
- fs.mkdirSync(dirPath, { recursive: true });
37
+ function resolvePersonaBindHost() {
38
+ const value = String(process.env.OOMI_PERSONA_BIND_HOST || '').trim();
39
+ return value || '127.0.0.1';
12
40
  }
13
41
 
14
- function wait(ms) {
15
- return new Promise((resolve) => {
16
- setTimeout(resolve, ms);
17
- });
42
+ function trimString(value) {
43
+ return typeof value === 'string' ? value.trim() : '';
18
44
  }
19
45
 
20
- function runProcess({ command, args, cwd }) {
21
- return new Promise((resolve, reject) => {
22
- const child = spawn(command, args, {
23
- cwd,
24
- stdio: 'inherit',
25
- shell: false,
26
- windowsHide: true,
27
- });
28
-
29
- child.on('error', reject);
30
- child.on('exit', (code) => {
31
- if (code === 0) {
32
- resolve();
33
- return;
46
+ function readProcessCommandLine(pid) {
47
+ const safePid = normalizePositiveInteger(pid);
48
+ if (!safePid) {
49
+ return '';
50
+ }
51
+
52
+ if (process.platform === 'linux') {
53
+ const procPath = `/proc/${safePid}/cmdline`;
54
+ if (fs.existsSync(procPath)) {
55
+ try {
56
+ return fs.readFileSync(procPath).toString().replace(/\u0000/g, ' ').trim();
57
+ } catch {
58
+ return '';
34
59
  }
35
- reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}.`));
36
- });
37
- });
38
- }
60
+ }
61
+ }
39
62
 
40
- export async function installPersonaWorkspace({
41
- workspacePath,
42
- }) {
43
- if (!workspacePath) {
44
- throw new Error('Workspace path is required.');
63
+ if (process.platform === 'win32') {
64
+ const result = spawnSync(
65
+ 'powershell',
66
+ [
67
+ '-NoProfile',
68
+ '-Command',
69
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${safePid}").CommandLine`,
70
+ ],
71
+ {
72
+ encoding: 'utf8',
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ }
75
+ );
76
+ return trimString(result.stdout || '');
45
77
  }
46
78
 
47
- await runProcess({
48
- command: resolveNpmCommand(),
49
- args: ['install'],
50
- cwd: workspacePath,
79
+ const result = spawnSync('ps', ['-o', 'command=', '-p', String(safePid)], {
80
+ encoding: 'utf8',
81
+ stdio: ['ignore', 'pipe', 'pipe'],
51
82
  });
83
+ return trimString(result.stdout || '');
52
84
  }
53
85
 
54
- export function startPersonaWorkspace({
55
- workspacePath,
56
- logFilePath,
57
- env = {},
58
- }) {
59
- if (!workspacePath) {
60
- throw new Error('Workspace path is required.');
86
+ export function matchesPersonaRuntimeCommand(commandLine, options = {}) {
87
+ const text = trimString(commandLine);
88
+ if (!text) {
89
+ return false;
61
90
  }
62
91
 
63
- const resolvedLogFilePath =
64
- logFilePath ||
65
- path.join(workspacePath, '.oomi', 'runtime.log');
66
- ensureDir(path.dirname(resolvedLogFilePath));
67
-
68
- const output = fs.openSync(resolvedLogFilePath, 'a');
69
- const child = spawn(resolveNpmCommand(), ['run', 'dev'], {
70
- cwd: workspacePath,
71
- detached: true,
72
- stdio: ['ignore', output, output],
73
- shell: false,
74
- windowsHide: true,
75
- env: {
76
- ...process.env,
77
- ...env,
78
- },
79
- });
80
-
81
- child.unref();
82
-
83
- return {
84
- pid: child.pid,
85
- logFilePath: resolvedLogFilePath,
86
- };
87
- }
88
-
89
- async function fetchHealth(url) {
90
- const response = await fetch(url, {
91
- method: 'GET',
92
- headers: {
93
- Accept: 'application/json',
94
- },
95
- });
92
+ const workspacePath = trimString(options.workspacePath);
93
+ const command = trimString(options.expectedCommand?.command);
94
+ const args = Array.isArray(options.expectedCommand?.args) ? options.expectedCommand.args : [];
95
+ const firstArg = trimString(args[0]);
96
+ const localPort = normalizePositiveInteger(options.localPort);
96
97
 
97
- if (!response.ok) {
98
- throw new Error(`Healthcheck returned ${response.status}.`);
98
+ if (firstArg && !text.includes(firstArg)) {
99
+ return false;
99
100
  }
100
101
 
101
- return response;
102
- }
102
+ if (workspacePath && !text.includes(workspacePath)) {
103
+ return false;
104
+ }
103
105
 
104
- export async function waitForPersonaRuntime({
105
- healthcheckUrl,
106
- timeoutMs = 45000,
107
- intervalMs = 1000,
108
- }) {
109
- if (!healthcheckUrl) {
110
- throw new Error('Healthcheck URL is required.');
106
+ if (localPort) {
107
+ const strictPortArg = `--port ${localPort}`;
108
+ if (!text.includes(strictPortArg) && !text.includes(`:${localPort}`)) {
109
+ return false;
110
+ }
111
111
  }
112
112
 
113
- const startedAt = Date.now();
114
- let lastError = null;
115
- while (Date.now() - startedAt <= timeoutMs) {
116
- try {
117
- await fetchHealth(healthcheckUrl);
118
- return;
119
- } catch (error) {
120
- lastError = error;
121
- await wait(intervalMs);
113
+ if (command) {
114
+ const commandBase = path.basename(command);
115
+ if (!text.includes(command) && !text.includes(commandBase)) {
116
+ return false;
122
117
  }
123
118
  }
124
119
 
125
- const message =
126
- lastError instanceof Error
127
- ? lastError.message
128
- : 'Timed out waiting for persona runtime healthcheck.';
129
- throw new Error(`Timed out waiting for persona runtime healthcheck: ${message}`);
120
+ return true;
130
121
  }
131
122
 
132
- export function buildLocalPersonaRuntime({
133
- localPort,
134
- healthPath,
135
- }) {
136
- const port = Number(localPort);
137
- if (!Number.isFinite(port) || port <= 0) {
138
- throw new Error('Local port is required.');
123
+ function resolveDirectViteCommand({ workspacePath, localPort }) {
124
+ if (!workspacePath) {
125
+ return null;
126
+ }
127
+
128
+ const viteScriptPath = path.join(workspacePath, 'node_modules', 'vite', 'bin', 'vite.js');
129
+ if (!fs.existsSync(viteScriptPath)) {
130
+ return null;
131
+ }
132
+
133
+ const port = normalizePositiveInteger(localPort);
134
+ const args = [viteScriptPath, '--host', resolvePersonaBindHost()];
135
+ if (port) {
136
+ args.push('--port', String(port), '--strictPort');
139
137
  }
138
+
139
+ return {
140
+ command: process.execPath,
141
+ args,
142
+ };
143
+ }
144
+
145
+ function wait(ms) {
146
+ return new Promise((resolve) => {
147
+ setTimeout(resolve, ms);
148
+ });
149
+ }
150
+
151
+ function runProcess({ command, args, cwd }) {
152
+ return new Promise((resolve, reject) => {
153
+ const child =
154
+ process.platform === 'win32'
155
+ ? spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', buildWindowsCommandLine(command, args)], {
156
+ cwd,
157
+ stdio: 'inherit',
158
+ shell: false,
159
+ windowsHide: true,
160
+ })
161
+ : spawn(command, args, {
162
+ cwd,
163
+ stdio: 'inherit',
164
+ shell: false,
165
+ windowsHide: true,
166
+ });
167
+
168
+ child.on('error', reject);
169
+ child.on('exit', (code) => {
170
+ if (code === 0) {
171
+ resolve();
172
+ return;
173
+ }
174
+ reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}.`));
175
+ });
176
+ });
177
+ }
178
+
179
+ export async function installPersonaWorkspace({
180
+ workspacePath,
181
+ }) {
182
+ if (!workspacePath) {
183
+ throw new Error('Workspace path is required.');
184
+ }
185
+
186
+ await runProcess({
187
+ command: resolveNpmCommand(),
188
+ args: ['install', '--silent', '--no-fund', '--no-audit'],
189
+ cwd: workspacePath,
190
+ });
191
+ }
192
+
193
+ export function resolvePersonaDevCommand({
194
+ workspacePath,
195
+ localPort,
196
+ }) {
197
+ const directCommand = resolveDirectViteCommand({ workspacePath, localPort });
198
+ if (directCommand) {
199
+ return directCommand;
200
+ }
140
201
 
141
- const endpoint = `http://127.0.0.1:${port}`;
202
+ const port = normalizePositiveInteger(localPort);
203
+ const args = ['run', 'dev'];
204
+ if (port) {
205
+ args.push('--', '--host', resolvePersonaBindHost(), '--port', String(port), '--strictPort');
206
+ }
142
207
  return {
143
- transport: 'local',
144
- endpoint,
145
- localPort: port,
146
- healthcheckUrl: `${endpoint}${healthPath}`,
147
- };
148
- }
208
+ command: resolveNpmCommand(),
209
+ args,
210
+ };
211
+ }
212
+
213
+ export function startPersonaWorkspace({
214
+ workspacePath,
215
+ logFilePath,
216
+ env = {},
217
+ localPort,
218
+ }) {
219
+ if (!workspacePath) {
220
+ throw new Error('Workspace path is required.');
221
+ }
222
+
223
+ const resolvedLogFilePath =
224
+ logFilePath ||
225
+ path.join(workspacePath, '.oomi', 'runtime.log');
226
+ ensureDir(path.dirname(resolvedLogFilePath));
227
+
228
+ const output = fs.openSync(resolvedLogFilePath, 'a');
229
+ const devCommand = resolvePersonaDevCommand({ workspacePath, localPort });
230
+ const needsWindowsShellWrapper =
231
+ process.platform === 'win32' && /\.cmd$/iu.test(path.basename(devCommand.command));
232
+
233
+ if (needsWindowsShellWrapper) {
234
+ fs.closeSync(output);
235
+ const shellCommand = `${buildWindowsCommandLine(
236
+ devCommand.command,
237
+ devCommand.args
238
+ )} >> "${resolvedLogFilePath.replace(/"/g, '""')}" 2>&1`;
239
+ const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', shellCommand], {
240
+ cwd: workspacePath,
241
+ detached: true,
242
+ stdio: 'ignore',
243
+ shell: false,
244
+ windowsHide: true,
245
+ env: {
246
+ ...process.env,
247
+ ...env,
248
+ },
249
+ });
250
+
251
+ child.unref();
252
+
253
+ const pid = normalizePositiveInteger(child.pid);
254
+ if (!pid) {
255
+ throw new Error('Failed to determine persona workspace process id on Windows.');
256
+ }
257
+
258
+ return {
259
+ pid,
260
+ logFilePath: resolvedLogFilePath,
261
+ };
262
+ }
263
+
264
+ let child;
265
+ try {
266
+ child = spawn(devCommand.command, devCommand.args, {
267
+ cwd: workspacePath,
268
+ detached: true,
269
+ stdio: ['ignore', output, output],
270
+ shell: false,
271
+ windowsHide: true,
272
+ env: {
273
+ ...process.env,
274
+ ...env,
275
+ },
276
+ });
277
+ } finally {
278
+ fs.closeSync(output);
279
+ }
280
+
281
+ child.unref();
282
+
283
+ return {
284
+ pid: child.pid,
285
+ logFilePath: resolvedLogFilePath,
286
+ };
287
+ }
288
+
289
+ export function isPersonaWorkspaceProcessRunning(pid, options = {}) {
290
+ const safePid = normalizePositiveInteger(pid);
291
+ if (!safePid) {
292
+ return false;
293
+ }
149
294
 
150
- export function defaultPersonaWorkspaceRoot() {
151
- return path.join(os.homedir(), '.openclaw', 'personas');
295
+ try {
296
+ process.kill(safePid, 0);
297
+ const hasExpectations =
298
+ trimString(options.workspacePath) ||
299
+ trimString(options.expectedCommand?.command) ||
300
+ normalizePositiveInteger(options.localPort);
301
+ if (!hasExpectations) {
302
+ return true;
303
+ }
304
+
305
+ const commandLine = readProcessCommandLine(safePid);
306
+ return matchesPersonaRuntimeCommand(commandLine, options);
307
+
308
+ return true;
309
+ } catch {
310
+ return false;
311
+ }
152
312
  }
313
+
314
+ export async function stopPersonaWorkspace({
315
+ pid,
316
+ waitMs = 4000,
317
+ }) {
318
+ const safePid = normalizePositiveInteger(pid);
319
+ if (!safePid || !isPersonaWorkspaceProcessRunning(safePid)) {
320
+ return false;
321
+ }
322
+
323
+ try {
324
+ process.kill(safePid, 'SIGTERM');
325
+ } catch {
326
+ return false;
327
+ }
328
+
329
+ const startedAt = Date.now();
330
+ while (Date.now() - startedAt <= waitMs) {
331
+ if (!isPersonaWorkspaceProcessRunning(safePid)) {
332
+ return true;
333
+ }
334
+ await wait(200);
335
+ }
336
+
337
+ try {
338
+ process.kill(safePid, 'SIGKILL');
339
+ } catch {
340
+ return !isPersonaWorkspaceProcessRunning(safePid);
341
+ }
342
+
343
+ return !isPersonaWorkspaceProcessRunning(safePid);
344
+ }
345
+
346
+ async function fetchHealth(url) {
347
+ const response = await fetch(url, {
348
+ method: 'GET',
349
+ headers: {
350
+ Accept: 'application/json',
351
+ },
352
+ });
353
+
354
+ if (!response.ok) {
355
+ throw new Error(`Healthcheck returned ${response.status}.`);
356
+ }
357
+
358
+ return response;
359
+ }
360
+
361
+ export async function waitForPersonaRuntime({
362
+ healthcheckUrl,
363
+ timeoutMs = 45000,
364
+ intervalMs = 1000,
365
+ }) {
366
+ if (!healthcheckUrl) {
367
+ throw new Error('Healthcheck URL is required.');
368
+ }
369
+
370
+ const startedAt = Date.now();
371
+ let lastError = null;
372
+ while (Date.now() - startedAt <= timeoutMs) {
373
+ try {
374
+ await fetchHealth(healthcheckUrl);
375
+ return;
376
+ } catch (error) {
377
+ lastError = error;
378
+ await wait(intervalMs);
379
+ }
380
+ }
381
+
382
+ const message =
383
+ lastError instanceof Error
384
+ ? lastError.message
385
+ : 'Timed out waiting for persona runtime healthcheck.';
386
+ throw new Error(`Timed out waiting for persona runtime healthcheck: ${message}`);
387
+ }
388
+
389
+ export function buildLocalPersonaRuntime({
390
+ localPort,
391
+ healthPath,
392
+ }) {
393
+ const port = Number(localPort);
394
+ if (!Number.isFinite(port) || port <= 0) {
395
+ throw new Error('Local port is required.');
396
+ }
397
+
398
+ const endpoint = `http://127.0.0.1:${port}`;
399
+ return {
400
+ transport: 'local',
401
+ endpoint,
402
+ localPort: port,
403
+ healthcheckUrl: `${endpoint}${healthPath}`,
404
+ };
405
+ }
406
+
407
+ export function defaultPersonaWorkspaceRoot() {
408
+ return resolveOpenclawPersonasDir();
409
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function ensureDir(dirPath) {
5
+ fs.mkdirSync(dirPath, { recursive: true });
6
+ }
7
+
8
+ function readJsonSafe(filePath) {
9
+ if (!fs.existsSync(filePath)) {
10
+ return null;
11
+ }
12
+
13
+ try {
14
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function trimString(value) {
21
+ return typeof value === 'string' ? value.trim() : '';
22
+ }
23
+
24
+ export function resolvePersonaWorkspacePath({ workspaceRoot, slug }) {
25
+ const safeRoot = trimString(workspaceRoot);
26
+ const safeSlug = trimString(slug);
27
+ if (!safeRoot) {
28
+ throw new Error('Workspace root is required.');
29
+ }
30
+ if (!safeSlug) {
31
+ throw new Error('Persona slug is required.');
32
+ }
33
+
34
+ return path.resolve(safeRoot, safeSlug);
35
+ }
36
+
37
+ export function resolvePersonaRuntimeDir(workspacePath) {
38
+ return path.join(workspacePath, '.oomi');
39
+ }
40
+
41
+ export function resolvePersonaRuntimeStatePath(workspacePath) {
42
+ return path.join(resolvePersonaRuntimeDir(workspacePath), 'runtime.json');
43
+ }
44
+
45
+ export function resolvePersonaRuntimeLogPath(workspacePath) {
46
+ return path.join(resolvePersonaRuntimeDir(workspacePath), 'runtime.log');
47
+ }
48
+
49
+ export function readPersonaRuntimeState(workspacePath) {
50
+ return readJsonSafe(resolvePersonaRuntimeStatePath(workspacePath)) || {};
51
+ }
52
+
53
+ export function writePersonaRuntimeState(workspacePath, state) {
54
+ const runtimeDir = resolvePersonaRuntimeDir(workspacePath);
55
+ const statePath = resolvePersonaRuntimeStatePath(workspacePath);
56
+ ensureDir(runtimeDir);
57
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8');
58
+ return state;
59
+ }
60
+
61
+ export function updatePersonaRuntimeState(workspacePath, partial) {
62
+ const current = readPersonaRuntimeState(workspacePath);
63
+ return writePersonaRuntimeState(workspacePath, {
64
+ ...current,
65
+ ...partial,
66
+ });
67
+ }