fuli-context 0.4.0 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fuli-graph-provider"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Graphiti-backed personal and shared project provider for Fuli."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "fuli-context",
9
- "version": "0.4.0",
9
+ "version": "0.5.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@hono/node-server": "2.0.12",
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local-first context graph for AI agents.",
5
5
  "type": "module",
6
6
  "files": [
7
+ "README.en.md",
7
8
  "npm-shrinkwrap.json",
8
9
  "src/",
9
10
  "dist/web/",
@@ -12,9 +12,9 @@ General:
12
12
  --version, -v
13
13
 
14
14
  Local service:
15
- start [--port 2727] [--open] [--rebuild] [--data-dir DIR] [--personal-space NAME]
15
+ start [--port 2727] [--open] [--lan] [--rebuild] [--data-dir DIR] [--personal-space NAME]
16
16
  stop [--data-dir DIR]
17
- restart [--port 2727] [--open] [--rebuild] [--data-dir DIR] [--personal-space NAME]
17
+ restart [--port 2727] [--open] [--lan] [--rebuild] [--data-dir DIR] [--personal-space NAME]
18
18
  status [--json] [--data-dir DIR]
19
19
  open [--data-dir DIR]
20
20
 
@@ -40,10 +40,10 @@ export async function runLocalRuntimeCommand(command, args, dependencies = {}) {
40
40
  export function formatLocalRuntimeResult(command, result) {
41
41
  if (command === 'start') {
42
42
  const verb = result.status === 'running' ? '已经在运行' : '已启动';
43
- return [`Fuli 本地服务${verb}。`, `管理界面:${result.url}`].join('\n');
43
+ return formatStartedRuntime(`Fuli 本地服务${verb}。`, result);
44
44
  }
45
45
  if (command === 'restart') {
46
- return [`Fuli 本地服务已重启。`, `管理界面:${result.url}`].join('\n');
46
+ return formatStartedRuntime('Fuli 本地服务已重启。', result);
47
47
  }
48
48
  if (command === 'stop') {
49
49
  if (result.status === 'partial') {
@@ -61,6 +61,20 @@ export function formatLocalRuntimeResult(command, result) {
61
61
  throw new TypeError(`Unknown local runtime command: ${command}`);
62
62
  }
63
63
 
64
+ function formatStartedRuntime(title, result) {
65
+ const lines = [title, `管理界面:${result.url}`];
66
+ if (result.lan === true) {
67
+ lines.push(
68
+ '局域网界面:',
69
+ ...result.lanUrls.map((url) => ` ${url}`),
70
+ `访问用户名:${result.lanAccess.username}`,
71
+ `临时访问口令:${result.lanAccess.accessCode}`,
72
+ '仅在可信 Wi-Fi 中使用;重新启动局域网模式会更换口令。'
73
+ );
74
+ }
75
+ return lines.join('\n');
76
+ }
77
+
64
78
  function formatStatus(result) {
65
79
  const labels = {
66
80
  running: '运行中',
@@ -9,13 +9,16 @@ const VALUE_OPTIONS = Object.freeze({
9
9
  const BOOLEAN_OPTIONS = Object.freeze({
10
10
  '--open': 'open',
11
11
  '--rebuild': 'rebuild',
12
+ '--lan': 'lan',
12
13
  '--json': 'json'
13
14
  });
14
15
 
15
16
  const ALLOWED_BY_COMMAND = Object.freeze({
16
- start: new Set(['--data-dir', '--personal-space', '--port', '--open', '--rebuild']),
17
+ start: new Set(['--data-dir', '--personal-space', '--port', '--open', '--rebuild', '--lan']),
17
18
  stop: new Set(['--data-dir']),
18
- restart: new Set(['--data-dir', '--personal-space', '--port', '--open', '--rebuild']),
19
+ restart: new Set([
20
+ '--data-dir', '--personal-space', '--port', '--open', '--rebuild', '--lan'
21
+ ]),
19
22
  status: new Set(['--data-dir', '--port', '--json']),
20
23
  open: new Set(['--data-dir'])
21
24
  });
@@ -29,6 +32,7 @@ export function parseLocalRuntimeOptions(command, args = []) {
29
32
  port: DEFAULT_FULI_PORT,
30
33
  open: false,
31
34
  rebuild: false,
35
+ lan: false,
32
36
  json: false
33
37
  };
34
38
  const seen = new Set();
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+
1
3
  import { sendJson } from './response.js';
2
4
 
3
5
  export function localServerAuthority(address) {
@@ -9,18 +11,38 @@ export function localServerAuthority(address) {
9
11
  }
10
12
 
11
13
  export function rejectDisallowedRequest({ request, response, authority }) {
14
+ return rejectRequestOutsidePolicy({ request, response, authority });
15
+ }
16
+
17
+ export function rejectRequestOutsidePolicy({
18
+ request,
19
+ response,
20
+ authority,
21
+ lanAuthorities = [],
22
+ lanAccessToken = null
23
+ }) {
12
24
  const origin = request.headers.origin;
13
25
  const fetchSite = request.headers['sec-fetch-site'];
26
+ const requestAuthority = request.headers.host;
27
+ const lanRequest = lanAuthorities.includes(requestAuthority);
28
+ const loopbackRequest = requestAuthority === authority;
14
29
  if (
15
30
  !authority ||
16
- request.headers.host !== authority ||
17
- (origin !== undefined && origin !== `http://${authority}`) ||
31
+ (requestAuthority !== authority && !lanRequest) ||
32
+ (lanAuthorities.length > 0 && loopbackRequest && !isLoopbackAddress(request.socket?.remoteAddress)) ||
33
+ (origin !== undefined && origin !== `http://${requestAuthority}`) ||
18
34
  (typeof fetchSite === 'string' && fetchSite.toLowerCase() === 'cross-site')
19
35
  ) {
20
36
  sendJson(response, 403, { error: 'Forbidden' });
21
37
  return true;
22
38
  }
23
39
 
40
+ if (lanRequest && !hasLanAccess(request.headers.authorization, lanAccessToken)) {
41
+ response.setHeader('www-authenticate', 'Basic realm="FULI LAN", charset="UTF-8"');
42
+ sendJson(response, 401, { error: 'LAN access code required' });
43
+ return true;
44
+ }
45
+
24
46
  if (requiresJson(request) && !isJson(request.headers['content-type'])) {
25
47
  sendJson(response, 415, { error: 'Unsupported media type' });
26
48
  return true;
@@ -29,6 +51,19 @@ export function rejectDisallowedRequest({ request, response, authority }) {
29
51
  return false;
30
52
  }
31
53
 
54
+ function hasLanAccess(authorization, accessToken) {
55
+ if (typeof accessToken !== 'string' || !accessToken) return false;
56
+ const expected = `Basic ${Buffer.from(`fuli:${accessToken}`).toString('base64')}`;
57
+ if (typeof authorization !== 'string' || authorization.length !== expected.length) {
58
+ return false;
59
+ }
60
+ return timingSafeEqual(Buffer.from(authorization), Buffer.from(expected));
61
+ }
62
+
63
+ function isLoopbackAddress(address) {
64
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
65
+ }
66
+
32
67
  function requiresJson(request) {
33
68
  if (request.method !== 'POST') return false;
34
69
  const pathname = new URL(request.url, 'http://127.0.0.1').pathname;
@@ -20,6 +20,7 @@ export async function startLocalRuntime(input, dependencies = {}) {
20
20
  paths: input.paths,
21
21
  personalSpaceName: input.personalSpaceName,
22
22
  port: input.port,
23
+ lan: input.lan === true,
23
24
  personalOnly: !managesDevelopmentWorkspace,
24
25
  buildProviders,
25
26
  noStart: false,
@@ -99,7 +100,10 @@ export async function inspectLocalRuntime(input, dependencies = {}) {
99
100
  console: {
100
101
  status: consoleReady ? 'ready' : processAlive ? 'unverified' : 'stopped',
101
102
  url: stateLooksOwned ? state.url : `http://127.0.0.1:${input.port}`,
102
- pid: processAlive ? state.pid : null
103
+ pid: processAlive ? state.pid : null,
104
+ ...(stateLooksOwned && state.lan === true
105
+ ? { lan: true, lanUrls: safeLanUrls(state.lanUrls) }
106
+ : {})
103
107
  },
104
108
  personal,
105
109
  public: {
@@ -237,6 +241,12 @@ function isRuntimeState(state) {
237
241
  Number.isInteger(state.pid) && typeof state.url === 'string';
238
242
  }
239
243
 
244
+ function safeLanUrls(value) {
245
+ return Array.isArray(value)
246
+ ? value.filter((url) => typeof url === 'string' && url.startsWith('http://'))
247
+ : [];
248
+ }
249
+
240
250
  function isProcessAlive(pid) {
241
251
  try { process.kill(pid, 0); return true; } catch { return false; }
242
252
  }
@@ -0,0 +1,52 @@
1
+ import { isIPv4 } from 'node:net';
2
+ import { networkInterfaces } from 'node:os';
3
+
4
+ export const LAN_ACCESS_USERNAME = 'fuli';
5
+
6
+ export function discoverLanAddresses(interfaces = networkInterfaces()) {
7
+ const addresses = [];
8
+ for (const entries of Object.values(interfaces ?? {})) {
9
+ for (const entry of entries ?? []) {
10
+ if (!entry || entry.internal || !isIpv4Family(entry.family)) continue;
11
+ if (!isPrivateLanIpv4(entry.address)) continue;
12
+ addresses.push(entry.address);
13
+ }
14
+ }
15
+ return [...new Set(addresses)].sort(compareIpv4);
16
+ }
17
+
18
+ export function lanConsoleUrls(addresses, port) {
19
+ return addresses.map((address) => `http://${consoleAuthority(address, port)}`);
20
+ }
21
+
22
+ export function lanServerAuthorities(addresses, port) {
23
+ return lanConsoleUrls(addresses, port).map((url) => new URL(url).host);
24
+ }
25
+
26
+ function isIpv4Family(family) {
27
+ return family === 'IPv4' || family === 4;
28
+ }
29
+
30
+ function isPrivateLanIpv4(address) {
31
+ if (!isIPv4(address)) return false;
32
+ const [first, second] = address.split('.').map(Number);
33
+ return first === 10 ||
34
+ (first === 172 && second >= 16 && second <= 31) ||
35
+ (first === 192 && second === 168) ||
36
+ (first === 169 && second === 254);
37
+ }
38
+
39
+ function consoleAuthority(address, port) {
40
+ return Number(port) === 80 ? address : `${address}:${port}`;
41
+ }
42
+
43
+ function compareIpv4(left, right) {
44
+ const leftParts = left.split('.').map(Number);
45
+ const rightParts = right.split('.').map(Number);
46
+ for (let index = 0; index < 4; index += 1) {
47
+ if (leftParts[index] !== rightParts[index]) {
48
+ return leftParts[index] - rightParts[index];
49
+ }
50
+ }
51
+ return 0;
52
+ }
package/src/server.js CHANGED
@@ -4,11 +4,16 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { handleApiRequest } from './http/api-router.js';
6
6
  import { mapHttpError } from './http/error-mapping.js';
7
- import { localServerAuthority, rejectDisallowedRequest } from './http/request-policy.js';
7
+ import { localServerAuthority, rejectRequestOutsidePolicy } from './http/request-policy.js';
8
8
  import { sendJson } from './http/response.js';
9
9
  import { serveStatic } from './http/static-handler.js';
10
10
  import { DEFAULT_FULI_PORT } from './defaults.js';
11
11
  import { resolveGraphRuntimeOptions } from './graphiti/runtime-config.js';
12
+ import {
13
+ discoverLanAddresses,
14
+ lanConsoleUrls,
15
+ lanServerAuthorities
16
+ } from './server/lan-access.js';
12
17
  import { createServerApplication } from './server/application-lifecycle.js';
13
18
  import { listenServer } from './server/listen.js';
14
19
 
@@ -26,8 +31,19 @@ export async function createServer(options = {}) {
26
31
  store,
27
32
  app,
28
33
  closeApplicationOnShutdown,
29
- isBlockedPort = isFetchBlockedPort
34
+ isBlockedPort = isFetchBlockedPort,
35
+ lan = false,
36
+ lanAccessToken = null,
37
+ lanAddresses: configuredLanAddresses = null
30
38
  } = options;
39
+ const lanEnabled = lan === true;
40
+ const lanToken = lanEnabled ? requiredLanToken(lanAccessToken) : null;
41
+ const lanAddresses = lanEnabled
42
+ ? [...new Set(configuredLanAddresses ?? discoverLanAddresses())]
43
+ : [];
44
+ if (lanEnabled && lanAddresses.length === 0) {
45
+ throw new Error('No private IPv4 LAN address is available');
46
+ }
31
47
  if (Number(port) !== 0 && isBlockedPort(port)) {
32
48
  throw new Error(`Port ${port} is blocked for local fetch`);
33
49
  }
@@ -45,15 +61,23 @@ export async function createServer(options = {}) {
45
61
 
46
62
  for (let attempt = 0; attempt < 20; attempt += 1) {
47
63
  let authority = null;
64
+ let allowedLanAuthorities = [];
48
65
  const server = createHttpServer((request, response) => {
49
- handleRequest({ request, response, app: application, authority }).catch((error) => {
66
+ handleRequest({
67
+ request,
68
+ response,
69
+ app: application,
70
+ authority,
71
+ lanAuthorities: allowedLanAuthorities,
72
+ lanAccessToken: lanToken
73
+ }).catch((error) => {
50
74
  const mapped = mapHttpError(error);
51
75
  sendJson(response, mapped.status, mapped.body);
52
76
  });
53
77
  });
54
78
 
55
79
  try {
56
- await listenServer(server, port);
80
+ await listenServer(server, port, lanEnabled ? '0.0.0.0' : '127.0.0.1');
57
81
  } catch (error) {
58
82
  runtime.close();
59
83
  throw error;
@@ -66,6 +90,9 @@ export async function createServer(options = {}) {
66
90
  throw new Error(`Port ${address.port} is blocked for local fetch`);
67
91
  }
68
92
  authority = localServerAuthority(address);
93
+ allowedLanAuthorities = lanEnabled
94
+ ? lanServerAuthorities(lanAddresses, address.port)
95
+ : [];
69
96
  if (!authority) {
70
97
  await new Promise((resolveClose) => server.close(resolveClose));
71
98
  runtime.close();
@@ -73,7 +100,12 @@ export async function createServer(options = {}) {
73
100
  }
74
101
 
75
102
  server.once('close', runtime.close);
76
- return { server, url: `http://${authority}`, app: application };
103
+ return {
104
+ server,
105
+ url: `http://${authority}`,
106
+ lanUrls: lanEnabled ? lanConsoleUrls(lanAddresses, address.port) : [],
107
+ app: application
108
+ };
77
109
  }
78
110
 
79
111
  runtime.close();
@@ -94,8 +126,21 @@ export function isFetchBlockedPort(port) {
94
126
  return FETCH_BLOCKED_PORTS.has(Number(port));
95
127
  }
96
128
 
97
- async function handleRequest({ request, response, app, authority }) {
98
- if (rejectDisallowedRequest({ request, response, authority })) return;
129
+ async function handleRequest({
130
+ request,
131
+ response,
132
+ app,
133
+ authority,
134
+ lanAuthorities,
135
+ lanAccessToken
136
+ }) {
137
+ if (rejectRequestOutsidePolicy({
138
+ request,
139
+ response,
140
+ authority,
141
+ lanAuthorities,
142
+ lanAccessToken
143
+ })) return;
99
144
  if (await handleApiRequest({ request, response, app })) return;
100
145
  serveStatic(new URL(request.url, 'http://127.0.0.1').pathname, response);
101
146
  }
@@ -110,7 +155,13 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
110
155
  async function runProgram(args) {
111
156
  const runtimeOptions = resolveGraphRuntimeOptions(args, process.env);
112
157
  const port = numberOption(args, '--port', DEFAULT_FULI_PORT);
113
- const { server, url } = await createServer({ ...runtimeOptions, port });
158
+ const lan = args.includes('--lan');
159
+ const { server, url, lanUrls } = await createServer({
160
+ ...runtimeOptions,
161
+ port,
162
+ lan,
163
+ lanAccessToken: lan ? process.env.FULI_LAN_ACCESS_TOKEN : null
164
+ });
114
165
  const handlers = new Map();
115
166
  const close = () => server.close();
116
167
  for (const signal of ['SIGINT', 'SIGTERM']) {
@@ -122,6 +173,14 @@ async function runProgram(args) {
122
173
  for (const [signal, handler] of handlers) process.off(signal, handler);
123
174
  });
124
175
  console.log(`复利工作台 running at ${url}`);
176
+ for (const lanUrl of lanUrls) console.log(`复利工作台 LAN at ${lanUrl}`);
177
+ }
178
+
179
+ function requiredLanToken(value) {
180
+ if (typeof value !== 'string' || value.length < 16) {
181
+ throw new TypeError('LAN mode requires a generated access token');
182
+ }
183
+ return value;
125
184
  }
126
185
 
127
186
  function numberOption(args, flag, fallback) {
@@ -15,6 +15,11 @@ import {
15
15
  ensureContainerRuntime,
16
16
  runDockerCompose
17
17
  } from './container-runtime.js';
18
+ import {
19
+ discoverLanAddresses,
20
+ LAN_ACCESS_USERNAME,
21
+ lanConsoleUrls
22
+ } from '../server/lan-access.js';
18
23
  import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
19
24
 
20
25
  export {
@@ -30,6 +35,11 @@ export const MANAGED_WORKSPACE_PROVIDER_URL = WORKSPACE_PROVIDER_URL;
30
35
 
31
36
  export async function ensureGraphRuntime(input, dependencies = {}) {
32
37
  const deps = graphDependencies(dependencies);
38
+ const lan = input.lan === true;
39
+ const lanAddresses = lan ? deps.discoverLanAddresses() : [];
40
+ if (lan && lanAddresses.length === 0) {
41
+ throw new Error('未发现可用的私有 IPv4 局域网地址,无法启动 LAN 模式。');
42
+ }
33
43
  const containerRuntime = await deps.ensureContainerRuntime({
34
44
  env: input.env ?? process.env,
35
45
  onProgress: input.onProgress
@@ -61,33 +71,52 @@ export async function ensureGraphRuntime(input, dependencies = {}) {
61
71
  const existing = deps.readState(input.paths.graphRuntimeStatePath);
62
72
  if (isGraphRuntimeState(existing) && deps.isProcessAlive(existing.pid)) {
63
73
  const healthy = await deps.webHealth(existing.url, existing.pid, existing.version);
64
- if (healthy && existing.port === input.port) {
65
- return { status: 'running', url: existing.url, pid: existing.pid };
74
+ if (
75
+ healthy &&
76
+ existing.port === input.port &&
77
+ exposureMatches(existing, { lan, lanAddresses })
78
+ ) {
79
+ return runtimeResult('running', existing);
66
80
  }
67
81
  if (!healthy) throw new Error('Recorded Fuli Graphiti runtime is not healthy');
68
82
  deps.stopProcess(existing.pid);
83
+ if (!await deps.waitForExit(existing.pid)) {
84
+ throw new Error('Fuli Graphiti console did not stop before changing exposure mode');
85
+ }
69
86
  }
87
+ const lanAccessToken = lan ? deps.createLanAccessToken() : null;
70
88
  const child = deps.spawnWebRuntime({
71
89
  paths: input.paths,
72
90
  nodePath: process.execPath,
73
- port: input.port
91
+ port: input.port,
92
+ lan,
93
+ lanAccessToken,
94
+ env: input.env ?? process.env
74
95
  });
75
96
  const url = `http://127.0.0.1:${input.port}`;
76
97
  if (!Number.isInteger(child.pid) || !await waitForWeb(url, child.pid, deps)) {
77
98
  if (Number.isInteger(child.pid)) deps.stopProcess(child.pid);
78
99
  throw new Error('Fuli Graphiti console could not start');
79
100
  }
80
- deps.writeState(input.paths.graphRuntimeStatePath, {
101
+ const state = {
81
102
  version: 3,
82
103
  pid: child.pid,
83
104
  url,
84
105
  port: input.port,
106
+ ...(lan
107
+ ? {
108
+ lan: true,
109
+ lanUrls: lanConsoleUrls(lanAddresses, input.port)
110
+ }
111
+ : {}),
85
112
  managedProviders: input.personalOnly
86
113
  ? ['personal']
87
114
  : ['personal', 'development-workspace'],
88
115
  startedAt: new Date().toISOString()
89
- });
90
- return { status: 'started', url, pid: child.pid };
116
+ };
117
+ deps.writeState(input.paths.graphRuntimeStatePath, state);
118
+ deps.secureFile(input.paths.graphRuntimeStatePath);
119
+ return runtimeResult('started', state, lanAccessToken);
91
120
  }
92
121
 
93
122
  function ensureProviderSecrets(path, deps) {
@@ -250,14 +279,19 @@ function spawnWebRuntime(input) {
250
279
  mkdirSync(dirname(input.paths.logPath), { recursive: true });
251
280
  const log = openSync(input.paths.logPath, 'a');
252
281
  try {
253
- const child = spawn(input.nodePath, [
282
+ const args = [
254
283
  input.paths.serverPath,
255
284
  '--runtime-config', input.paths.graphRuntimeConfigPath,
256
285
  '--port', String(input.port)
257
- ], {
286
+ ];
287
+ if (input.lan) args.push('--lan');
288
+ const child = spawn(input.nodePath, args, {
258
289
  detached: true,
259
290
  windowsHide: true,
260
- stdio: ['ignore', log, log]
291
+ stdio: ['ignore', log, log],
292
+ env: input.lan
293
+ ? { ...input.env, FULI_LAN_ACCESS_TOKEN: input.lanAccessToken }
294
+ : input.env
261
295
  });
262
296
  child.unref();
263
297
  return child;
@@ -292,6 +326,9 @@ function graphDependencies(overrides) {
292
326
  stopProcess,
293
327
  stopLegacyService,
294
328
  webHealth: checkLocalConsoleHealth,
329
+ discoverLanAddresses,
330
+ createLanAccessToken: secret,
331
+ waitForExit: waitForProcessExit,
295
332
  ...overrides
296
333
  };
297
334
  }
@@ -323,6 +360,30 @@ function isGraphRuntimeState(state) {
323
360
  Number.isInteger(state.pid) && typeof state.url === 'string';
324
361
  }
325
362
 
363
+ function exposureMatches(state, { lan }) {
364
+ if ((state.lan === true) !== lan) return false;
365
+ if (!lan) return true;
366
+ // A repeated explicit LAN start rotates the in-memory access code.
367
+ return false;
368
+ }
369
+
370
+ function runtimeResult(status, state, lanAccessToken = null) {
371
+ const result = { status, url: state.url, pid: state.pid };
372
+ if (state.lan !== true) return result;
373
+ if (typeof lanAccessToken !== 'string' || lanAccessToken.length < 16) {
374
+ throw new Error('LAN runtime started without a valid access code');
375
+ }
376
+ return {
377
+ ...result,
378
+ lan: true,
379
+ lanUrls: [...state.lanUrls],
380
+ lanAccess: {
381
+ username: LAN_ACCESS_USERNAME,
382
+ accessCode: lanAccessToken
383
+ }
384
+ };
385
+ }
386
+
326
387
  function stopLegacyService() {
327
388
  if (process.platform !== 'darwin') return;
328
389
  spawnSync('launchctl', ['remove', 'dev.jevonscode.fuli.local'], {
@@ -355,6 +416,14 @@ function stopProcess(pid) {
355
416
  try { process.kill(pid, 'SIGTERM'); } catch { /* already stopped */ }
356
417
  }
357
418
 
419
+ async function waitForProcessExit(pid) {
420
+ for (let attempt = 0; attempt < 50; attempt += 1) {
421
+ if (!isProcessAlive(pid)) return true;
422
+ await delay(100);
423
+ }
424
+ return false;
425
+ }
426
+
358
427
  function delay(ms) {
359
428
  return new Promise((resolve) => setTimeout(resolve, ms));
360
429
  }
@@ -1 +0,0 @@
1
- .batch-confirm-dialog[data-v-5b6ea24b]{width:min(980px,100vw - 64px)}.batch-confirm-form[data-v-5b6ea24b]{grid-template-columns:320px minmax(0,1fr);gap:24px;padding-top:20px;display:grid}.batch-confirm-controls[data-v-5b6ea24b]{align-content:start;gap:14px;display:grid}.batch-confirm-controls>label[data-v-5b6ea24b],.batch-confirmer-fields label[data-v-5b6ea24b]{color:#5f6962;gap:6px;font-size:10px;font-weight:600;display:grid}.batch-confirm-controls[data-v-5b6ea24b] .searchable-select{width:100%}.batch-group-summary[data-v-5b6ea24b]{border-top:1px solid #e1e5e2;border-bottom:1px solid #e1e5e2;gap:4px;padding:12px 0;display:grid}.batch-group-summary span[data-v-5b6ea24b],.batch-group-summary small[data-v-5b6ea24b]{color:#89918b;font-size:8px}.batch-group-summary strong[data-v-5b6ea24b]{color:#35443b;font-size:12px}.batch-group-summary p[data-v-5b6ea24b]{color:#6e7871;font-size:9px;line-height:1.5}.batch-confirmer-fields[data-v-5b6ea24b]{grid-template-columns:112px minmax(0,1fr);gap:8px;display:grid}.batch-confirm-rule[data-v-5b6ea24b],.batch-limit-note[data-v-5b6ea24b]{color:#7c857f;font-size:9px;line-height:1.5}.batch-confirm-review[data-v-5b6ea24b]{border-left:1px solid #e1e5e2;min-width:0;padding-left:24px}.batch-review-heading[data-v-5b6ea24b]{justify-content:space-between;align-items:center;gap:16px;padding-bottom:10px;display:flex}.batch-review-heading h4[data-v-5b6ea24b]{color:#344239;font-size:13px}.batch-review-heading p[data-v-5b6ea24b]{color:#858e87;margin-top:2px;font-size:9px}.batch-review-list[data-v-5b6ea24b]{border-top:1px solid #e3e6e3;border-bottom:1px solid #e3e6e3;max-height:410px;overflow:auto}.batch-review-item[data-v-5b6ea24b]{cursor:pointer;border-bottom:1px solid #eceeec;grid-template-columns:auto minmax(0,1fr);gap:9px;padding:10px 2px;display:grid}.batch-review-item[data-v-5b6ea24b]:last-child{border-bottom:0}.batch-review-item>input[data-v-5b6ea24b]{margin-top:2px}.batch-review-item>span[data-v-5b6ea24b]{gap:3px;min-width:0;display:grid}.batch-review-item strong[data-v-5b6ea24b]{color:#3a4740;font-size:10px}.batch-review-item small[data-v-5b6ea24b]{color:#77817a;font-size:8px}.batch-review-item em[data-v-5b6ea24b]{color:#848d87;text-overflow:ellipsis;white-space:nowrap;font-size:8px;font-style:normal;line-height:1.4;overflow:hidden}.batch-confirm-acknowledgement[data-v-5b6ea24b]{color:#4f5d55;cursor:pointer;align-items:flex-start;gap:8px;margin-top:12px;font-size:9px;line-height:1.5;display:flex}.batch-confirm-form>.publish-dialog-error[data-v-5b6ea24b],.batch-confirm-form>.publish-dialog-actions[data-v-5b6ea24b]{grid-column:1/-1}@media (width<=820px){.batch-confirm-form[data-v-5b6ea24b]{grid-template-columns:1fr}.batch-confirm-review[data-v-5b6ea24b]{border-left:0;padding:0}}.knowledge-organizer[data-v-e7137842]{flex-direction:column;gap:12px;padding:18px 24px 20px;display:flex;overflow:hidden}.organizer-head[data-v-e7137842]{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px 24px;min-width:0;display:grid}.organizer-principle[data-v-e7137842]{align-self:center;min-width:0;padding:0 2px}.organizer-principle h3[data-v-e7137842]{font-size:16px}.organizer-principle>p[data-v-e7137842]:last-child{color:#6e7771;max-width:760px;margin-top:5px;font-size:11px;line-height:1.55}.organizer-attention[data-v-e7137842]{text-align:right;border-left:1px solid #dde2de;grid-area:1/2;min-width:152px;padding-left:20px}.organizer-attention strong[data-v-e7137842],.organizer-attention span[data-v-e7137842],.organizer-attention small[data-v-e7137842]{display:block}.organizer-attention strong[data-v-e7137842]{color:#6f582b;font-size:23px;font-weight:620}.organizer-attention span[data-v-e7137842]{color:#5f675f;font-size:10px;font-weight:650}.organizer-attention small[data-v-e7137842]{color:#8a918b;margin-top:2px;font-size:8px}.organizer-loading-inline[data-v-e7137842]{color:#7b847e;text-align:center;font-size:10px}.organizer-toolbar[data-v-e7137842]{align-items:center;gap:8px;min-height:34px;display:flex}.organizer-search[data-v-e7137842]{flex:300px;min-width:180px;max-width:360px}.organizer-search>span[data-v-e7137842]{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.organizer-search input[data-v-e7137842]{width:100%}.review-state-filter[data-v-e7137842],.quadrant-filter[data-v-e7137842]{white-space:nowrap;background:#f1f4f1;border:1px solid #d7ddd8;border-radius:7px;flex:none;align-items:center;gap:2px;height:31px;padding:2px;display:flex}.quadrant-filter[data-v-e7137842]{max-width:470px;overflow-x:auto}.review-state-filter>span[data-v-e7137842],.quadrant-filter>span[data-v-e7137842]{color:#7a847d;padding:0 6px;font-size:8px;font-weight:650}.review-state-filter button[data-v-e7137842],.quadrant-filter button[data-v-e7137842]{color:#626d66;white-space:nowrap;background:0 0;border:0;border-radius:5px;height:25px;padding:0 7px;font-size:9px}.review-state-filter button[data-v-e7137842]:hover,.quadrant-filter button[data-v-e7137842]:hover{color:#35473d;background:#ffffffa8}.review-state-filter button.active[data-v-e7137842],.quadrant-filter button.active[data-v-e7137842]{color:#30483a;background:#fff;box-shadow:0 1px 3px #2f3d341f}.review-state-filter button.state-pending.active[data-v-e7137842]{color:#765d2e}.review-state-filter button.state-agent_confirmed.active[data-v-e7137842]{color:#4d5f78}.review-state-filter button.state-confirmed.active[data-v-e7137842]{color:#356047}.review-state-filter button strong[data-v-e7137842],.quadrant-filter button strong[data-v-e7137842]{margin-left:2px;font-size:8px;font-weight:720}.organizer-result-summary[data-v-e7137842]{color:#7b847e;white-space:nowrap;flex:none;font-size:9px}.organizer-toolbar .batch-confirm-action[data-v-e7137842]{margin-left:auto}.organizer-toolbar .toolbar-action+.toolbar-action[data-v-e7137842]{margin-left:0}.organizer-truncated[data-v-e7137842]{color:#806536;margin:-4px 0 0;font-size:9px}.organizer-layout[data-v-e7137842]{background:#fff;border:1px solid #cfd7d1;border-radius:8px;flex:1;grid-template-columns:minmax(0,1fr);min-height:0;display:grid;overflow:hidden}.organizer-layout.has-selection[data-v-e7137842]{grid-template-columns:minmax(0,1fr) 340px}.organizer-layout[data-v-e7137842]:not(.has-selection) .graph-inspector{display:none}.organizer-directory[data-v-e7137842]{overscroll-behavior:contain;min-width:0;overflow:auto}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(230px,1.35fr) 114px 130px minmax(160px,.8fr) 118px;align-items:center;gap:12px;min-width:920px;display:grid}.organizer-table-head[data-v-e7137842]{z-index:2;color:#8b938d;background:#f7f8f6;border-bottom:1px solid #e0e4e0;padding:10px 15px;font-size:9px;font-weight:680;position:sticky;top:0}.organizer-row[data-v-e7137842]{color:#4f5952;text-align:left;background:#fff;border:0;border-bottom:1px solid #e8ebe8;width:100%;min-height:68px;padding:11px 15px}.organizer-row[data-v-e7137842]:hover{background:#f8faf8}.organizer-row.selected[data-v-e7137842]{background:#f1f6f3;box-shadow:inset 2px 0 #557863}.organizer-item-copy[data-v-e7137842]{gap:4px;min-width:0;display:grid}.organizer-item-copy strong[data-v-e7137842],.organizer-item-copy small[data-v-e7137842]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.organizer-item-copy strong[data-v-e7137842]{color:#2f3933;font-size:11px;font-weight:630}.organizer-item-copy small[data-v-e7137842],.organizer-basis[data-v-e7137842],.organizer-row time[data-v-e7137842]{color:#7b847e;font-size:9px}.quadrant-chip[data-v-e7137842],.review-chip[data-v-e7137842]{white-space:nowrap;border-radius:999px;width:fit-content;padding:4px 7px;font-size:9px;font-weight:650}.quadrant-chip[data-v-e7137842]{color:#59655e;background:#f0f2ef;gap:1px;display:grid}.quadrant-chip small[data-v-e7137842]{color:inherit;opacity:.75;font-size:7px;font-weight:520}.quadrant-chip.known_known[data-v-e7137842]{color:#356047;background:#edf6f0}.quadrant-chip.known_unknown[data-v-e7137842]{color:#77602f;background:#f7f2e5}.quadrant-chip.unknown_known[data-v-e7137842]{color:#4d5f78;background:#eef2f7}.quadrant-chip.unknown_unknown[data-v-e7137842]{color:#725d70;background:#f5eff4}.review-chip.state-confirmed[data-v-e7137842]{color:#356047;background:#edf6f0}.review-chip.state-agent_confirmed[data-v-e7137842]{color:#4d5f78;background:#eef2f7}.review-chip.state-pending[data-v-e7137842]{color:#77602f;background:#f7f2e5}.organizer-basis[data-v-e7137842]{overflow-wrap:anywhere}@media (width<=1260px){.review-state-filter>span[data-v-e7137842],.quadrant-filter>span[data-v-e7137842],.organizer-result-summary[data-v-e7137842]{display:none}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(210px,1fr) 108px 104px minmax(140px,.8fr) 104px;min-width:760px}}@media (width<=1040px){.organizer-layout.has-selection[data-v-e7137842]{grid-template-columns:minmax(0,1fr) 280px}.organizer-table-head[data-v-e7137842],.organizer-row[data-v-e7137842]{grid-template-columns:minmax(210px,1fr) 108px 104px;min-width:470px}.organizer-table-head[data-v-e7137842]>:nth-child(n+4),.organizer-basis[data-v-e7137842],.organizer-row time[data-v-e7137842]{display:none}.batch-confirm-action[data-v-e7137842]{text-overflow:ellipsis;white-space:nowrap;max-width:108px;overflow:hidden}}