hoi4-agent-tools 3.8.0 → 3.9.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.
- package/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/dist/bin/setup.js +5 -2
- package/dist/bin/stdio.js +27 -1
- package/dist/hoi4_agent_tools/core/configuration.js +8 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +40 -2
- package/dist/hoi4_agent_tools/core/job-worker.js +14 -0
- package/dist/hoi4_agent_tools/core/scenario-model.js +1 -1
- package/dist/hoi4_agent_tools/core/transactions.js +31 -2
- package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +48 -0
- package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
- package/dist/hoi4_agent_tools/gui/studio.js +15 -9
- package/dist/hoi4_agent_tools/gui/validators.js +22 -0
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +83 -0
- package/dist/hoi4_agent_tools/reference/event-popups.js +87 -0
- package/dist/hoi4_agent_tools/reference/operations.js +151 -1
- package/dist/hoi4_agent_tools/reference/player-text.js +158 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +315 -33
- package/dist/hoi4_agent_tools/reference/source-locate.js +1 -1
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +18 -4
- package/dist/hoi4_agent_tools/schemas/script-validation.js +40 -7
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +25 -0
- package/docs/jobs.md +8 -0
- package/docs/reference.md +38 -0
- package/docs/setup.md +10 -0
- package/docs/tools.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +163 -128
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +38 -22
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +60 -36
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 3.9.0 - 2026-10-06
|
|
4
|
+
|
|
5
|
+
- Exit a stdio server whose launching process has exited, and add `stdioIdleExitMinutes` so clients that keep idle per-subagent connections no longer accumulate servers.
|
|
6
|
+
- Stop a read-only job whose worker outlives `jobDeadlineSeconds` (`JOB_DEADLINE_EXCEEDED`) or ignores cancellation for `jobCancelGraceSeconds`, so a blocked worker can no longer hang a workflow.
|
|
7
|
+
- Stop an orphaned read-only job worker at `jobDeadlineSeconds` even after its dispatching server has exited, and add `jobWorkerMaxHeapMiB` to bound each worker's heap.
|
|
8
|
+
- Check a whole mod file with `hoi4.script_validate` `path`: every event, decision, focus, scripted helper, and on-action body with the scope its structure fixes, the mod's own scripted helpers as known commands, and building, ideology, and resource trigger keys.
|
|
9
|
+
- Report a dynamic variable compared as a trigger (`SCRIPT_DYNAMIC_VARIABLE_AS_TRIGGER`) and a scope link outside its documented source scopes (`SCRIPT_SCOPE_LINK_WRONG_SCOPE`), and enter `owner`, `controller`, `capital_scope`, `overlord`, and `faction_leader` blocks with their target scope.
|
|
10
|
+
- Check player-facing text in `hoi4.script_validate` file mode: colour codes in news and report events, including through scripted localisation, development wording in shown text, and unlocalised flags in decision and focus requirement tooltips.
|
|
11
|
+
- Report visible events fired inside `every_*` loops that reach one player once per iteration, either through a fixed recipient or as a major event (`SCRIPT_EVENT_POPUP_REPEATED`).
|
|
12
|
+
- Publish the GUI scenario shape on `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite`, with `id` required, so clients can build a valid scenario from discovery.
|
|
13
|
+
- Add `closedFlags` to GUI scenarios: undeclared flags are unset, as in the game, so flag-driven tab pages and panels render one at a time.
|
|
14
|
+
- Resolve a window's scripted localisation exactly under the supplied scenario wherever its flags, variables, and values decide the branch; undecided tokens still render as `[dynamic_loc]`.
|
|
15
|
+
- Warn with `GUI_UNRESOLVED_VISIBILITY_OVERLAP` when overlapping elements are drawn together because the scenario does not decide which one is visible.
|
|
16
|
+
- Warn with `GUI_NEAR_CENTRE_OFFSET` when an element sits a few pixels from its parent's horizontal centre.
|
|
17
|
+
- Add `background: "transparent"` to GUI scenarios for renders without the preview backdrop.
|
|
18
|
+
|
|
19
|
+
## 3.8.1 - 2026-10-05
|
|
20
|
+
|
|
21
|
+
- Start normally when finished rewrite journals were recorded before the workspace roots changed, such as before installed DLC folders became source layers, and let them expire; an interrupted rewrite under changed roots is still refused.
|
|
4
22
|
|
|
5
23
|
## 3.8.0 - 2026-10-04
|
|
6
24
|
|
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ Your agent can select them from ordinary language; use the live tool schema for
|
|
|
119
119
|
| `hoi4.reference_search` | Search local documentation in plain words or script names; each result names its answering line. |
|
|
120
120
|
| `hoi4.reference_read` | Read one revision-bound section, with line continuation. |
|
|
121
121
|
| `hoi4.source_lookup` | Find exact definitions, overrides and usages across game, DLC and mod, or the definition containing a file line. |
|
|
122
|
-
| `hoi4.script_validate` | Check snippet syntax,
|
|
122
|
+
| `hoi4.script_validate` | Check a snippet or a whole mod file for syntax, command kinds, and scopes against local documentation. |
|
|
123
123
|
| `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
|
|
124
124
|
| `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
|
|
125
125
|
| `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
|
package/dist/bin/setup.js
CHANGED
|
@@ -225,8 +225,11 @@ function printClientConfig(configPath) {
|
|
|
225
225
|
args: [],
|
|
226
226
|
...(useDefaultConfiguration ? {} : { env: { HOI4_AGENT_CONFIG: absolute } }),
|
|
227
227
|
};
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
// Codex keeps a server per subagent connected after the subagent finishes, so its servers
|
|
229
|
+
// exit after half an hour without traffic instead of accumulating.
|
|
230
|
+
const codexEnv = `\nenv = { ${useDefaultConfiguration ? '' : `HOI4_AGENT_CONFIG = ${JSON.stringify(absolute)}, `}HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES = "30" }`;
|
|
231
|
+
const npxToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${command}"\nargs = ["-y", "hoi4-agent-tools@${PACKAGE_VERSION}"]${codexEnv}`;
|
|
232
|
+
const globalToml = `[mcp_servers.hoi4_agent_tools]\ncommand = "${installedCommand}"${codexEnv}`;
|
|
230
233
|
process.stdout.write(`${JSON.stringify({
|
|
231
234
|
note: 'Review and paste one example into your MCP client; this utility does not edit client settings.',
|
|
232
235
|
generic: { mcpServers: { hoi4_agent_tools: common } },
|
package/dist/bin/stdio.js
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
|
|
3
3
|
import { StdioFrameLimitError, StdioInvalidMessageError, StdioInvalidUtf8Error, } from '../hoi4_agent_tools/mcp/transports/bounded-stdio.js';
|
|
4
4
|
import { serveNegotiatedStdio } from '../hoi4_agent_tools/mcp/transports/negotiated-stdio.js';
|
|
5
|
+
import { StdioLifetime } from '../hoi4_agent_tools/mcp/transports/stdio-lifetime.js';
|
|
5
6
|
import { createEngine } from '../hoi4_agent_tools/runtime.js';
|
|
7
|
+
/**
|
|
8
|
+
* A client registration can set its own idle limit, because the server configuration is shared
|
|
9
|
+
* by every client: clients that keep idle per-subagent connections opt in without affecting a
|
|
10
|
+
* long-lived interactive session.
|
|
11
|
+
*/
|
|
12
|
+
function idleExitMinutes(configured) {
|
|
13
|
+
const override = process.env.HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES;
|
|
14
|
+
if (override === undefined || override.trim() === '')
|
|
15
|
+
return configured;
|
|
16
|
+
const minutes = Number(override);
|
|
17
|
+
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 10_080)
|
|
18
|
+
throw new Error('HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES must be a whole number from 0 to 10080');
|
|
19
|
+
return minutes;
|
|
20
|
+
}
|
|
6
21
|
async function main() {
|
|
7
22
|
const engine = await createEngine();
|
|
8
23
|
const chaosxTools = process.env.HOI4_AGENT_TOOLS_CHAOSX === '1'
|
|
@@ -20,8 +35,17 @@ async function main() {
|
|
|
20
35
|
message: error.message,
|
|
21
36
|
})}\n`);
|
|
22
37
|
};
|
|
23
|
-
|
|
38
|
+
let closeTransport = () => Promise.resolve();
|
|
39
|
+
const lifetime = new StdioLifetime({
|
|
40
|
+
idleExitMinutes: idleExitMinutes(engine.resolver.config().stdioIdleExitMinutes),
|
|
41
|
+
onExit: (reason) => {
|
|
42
|
+
process.stderr.write(`${canonicalJson({ level: 'info', event: 'stdio_exit', reason })}\n`);
|
|
43
|
+
void closeTransport().finally(() => setTimeout(() => process.exit(0), 250).unref());
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const transport = await serveNegotiatedStdio(engine, {
|
|
24
47
|
onerror,
|
|
48
|
+
observer: lifetime,
|
|
25
49
|
onFailure: (error) => {
|
|
26
50
|
process.exitCode = 1;
|
|
27
51
|
process.stderr.write(`${canonicalJson({
|
|
@@ -37,6 +61,8 @@ async function main() {
|
|
|
37
61
|
registerLegacy: (server) => chaosxTools.registerChaosxTools(server, engine, {}),
|
|
38
62
|
}),
|
|
39
63
|
});
|
|
64
|
+
closeTransport = () => transport.close();
|
|
65
|
+
lifetime.start();
|
|
40
66
|
}
|
|
41
67
|
main().catch((error) => {
|
|
42
68
|
process.stderr.write(`${canonicalJson({
|
|
@@ -147,6 +147,14 @@ export const serverConfigurationSchema = z
|
|
|
147
147
|
scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
|
|
148
148
|
maxConcurrentTools: z.number().int().min(1).max(32).default(2),
|
|
149
149
|
maxSharedTools: z.number().int().min(1).max(128).default(4),
|
|
150
|
+
/** A read-only job still running after this many seconds is stopped and fails. */
|
|
151
|
+
jobDeadlineSeconds: z.number().int().min(1).max(86_400).default(1_800),
|
|
152
|
+
/** A read-only job that has not stopped this long after cancellation is stopped. */
|
|
153
|
+
jobCancelGraceSeconds: z.number().int().min(1).max(600).default(10),
|
|
154
|
+
/** A stdio server with no client traffic or open request for this long exits; 0 never. */
|
|
155
|
+
stdioIdleExitMinutes: z.number().int().min(0).max(10_080).default(0),
|
|
156
|
+
/** V8 heap ceiling for each job worker in MiB; unset keeps Node's default. */
|
|
157
|
+
jobWorkerMaxHeapMiB: z.number().int().min(256).max(65_536).optional(),
|
|
150
158
|
scanMaxBytes: z
|
|
151
159
|
.number()
|
|
152
160
|
.int()
|
|
@@ -57,7 +57,18 @@ export function workerFailure(error, stage) {
|
|
|
57
57
|
message: `Worker coordination could not complete (${nativeCode}, stage ${stage}); inspect the retained job outcome`,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
60
|
+
/** Stop a supervised worker and wait, bounded, until the operating system reports its exit. */
|
|
61
|
+
async function stopChild(child) {
|
|
62
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
63
|
+
return;
|
|
64
|
+
const exited = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
65
|
+
child.kill('SIGKILL');
|
|
66
|
+
await Promise.race([exited, delay(10_000)]);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Bounded, fixed-entry child execution. Client disconnection never kills a worker; an
|
|
70
|
+
* overdue or unresponsive cancelled read-only worker is stopped by its supervising host.
|
|
71
|
+
*/
|
|
61
72
|
export class JobWorkerHost {
|
|
62
73
|
engine;
|
|
63
74
|
jobs;
|
|
@@ -189,7 +200,13 @@ export class JobWorkerHost {
|
|
|
189
200
|
}
|
|
190
201
|
: grant;
|
|
191
202
|
progress.stage = 'startup';
|
|
192
|
-
const child = spawn(process.execPath, [
|
|
203
|
+
const child = spawn(process.execPath, [
|
|
204
|
+
...(configuration.jobWorkerMaxHeapMiB === undefined
|
|
205
|
+
? []
|
|
206
|
+
: [`--max-old-space-size=${configuration.jobWorkerMaxHeapMiB}`]),
|
|
207
|
+
...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []),
|
|
208
|
+
entry,
|
|
209
|
+
], {
|
|
193
210
|
detached: true,
|
|
194
211
|
cwd: process.cwd(),
|
|
195
212
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
@@ -265,6 +282,9 @@ export class JobWorkerHost {
|
|
|
265
282
|
clearTimeout(acknowledgmentTimer);
|
|
266
283
|
}
|
|
267
284
|
progress.stage = 'supervision';
|
|
285
|
+
const { jobDeadlineSeconds, jobCancelGraceSeconds } = this.engine.resolver.config();
|
|
286
|
+
const supervisedSince = Date.now();
|
|
287
|
+
let cancellationSeenAt;
|
|
268
288
|
for (;;) {
|
|
269
289
|
const record = await this.jobs.get(workspaceId, id, principal);
|
|
270
290
|
if (['completed', 'failed', 'cancelled'].includes(record.status))
|
|
@@ -273,6 +293,24 @@ export class JobWorkerHost {
|
|
|
273
293
|
if (jobOwnerLiveness(record.owner) === 'alive')
|
|
274
294
|
return;
|
|
275
295
|
}
|
|
296
|
+
// A worker blocked in synchronous analysis never reaches its own cancellation check,
|
|
297
|
+
// so this process enforces the deadline and cancellation through the child handle.
|
|
298
|
+
// Rewrites are never stopped here: their transaction journal owns write recovery.
|
|
299
|
+
if (!record.request.mutation) {
|
|
300
|
+
if (record.cancelRequested)
|
|
301
|
+
cancellationSeenAt ??= Date.now();
|
|
302
|
+
const overdue = Date.now() - supervisedSince > jobDeadlineSeconds * 1000;
|
|
303
|
+
const unresponsive = cancellationSeenAt !== undefined &&
|
|
304
|
+
Date.now() - cancellationSeenAt > jobCancelGraceSeconds * 1000;
|
|
305
|
+
if (overdue || unresponsive) {
|
|
306
|
+
await stopChild(child);
|
|
307
|
+
await this.jobs.failInterrupted(workspaceId, id, {
|
|
308
|
+
code: 'JOB_DEADLINE_EXCEEDED',
|
|
309
|
+
message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
|
|
310
|
+
}, principal);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
276
314
|
if (child.pid !== undefined) {
|
|
277
315
|
try {
|
|
278
316
|
process.kill(child.pid, 0);
|
|
@@ -42,6 +42,20 @@ process.once('message', (value) => {
|
|
|
42
42
|
// unrelated live rewrite journals when a worker starts.
|
|
43
43
|
const jobs = await JobService.create(resolver);
|
|
44
44
|
const record = await jobs.get(input.workspaceId, input.jobId, input.principal);
|
|
45
|
+
if (!record.request.mutation) {
|
|
46
|
+
// The host enforces the deadline while it lives; a worker whose host has gone stops
|
|
47
|
+
// itself, so an orphaned read-only analysis never holds memory indefinitely.
|
|
48
|
+
const { jobDeadlineSeconds } = resolver.config();
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
void jobs
|
|
51
|
+
.failInterrupted(input.workspaceId, input.jobId, {
|
|
52
|
+
code: 'JOB_DEADLINE_EXCEEDED',
|
|
53
|
+
message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
|
|
54
|
+
}, input.principal)
|
|
55
|
+
.catch(() => undefined)
|
|
56
|
+
.finally(() => process.exit(1));
|
|
57
|
+
}, jobDeadlineSeconds * 1000).unref();
|
|
58
|
+
}
|
|
45
59
|
const operations = await registerWorkerOperations(engine, record.request.toolName);
|
|
46
60
|
await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
|
|
47
61
|
})()
|
|
@@ -56,7 +56,7 @@ export function scenarioFromGui(base, values = base.values) {
|
|
|
56
56
|
...(base.controls === undefined ? {} : { controls: { ...base.controls } }),
|
|
57
57
|
state,
|
|
58
58
|
scopes,
|
|
59
|
-
closedFlags:
|
|
59
|
+
closedFlags: base.closedFlags === true,
|
|
60
60
|
...(typeof actor === 'string' ? { actor } : {}),
|
|
61
61
|
};
|
|
62
62
|
}
|
|
@@ -599,10 +599,19 @@ export class TransactionManager {
|
|
|
599
599
|
continue;
|
|
600
600
|
const manifest = await this.load(workspace, entry.name, {
|
|
601
601
|
headMode: 'none',
|
|
602
|
+
rootBinding: 'ignored',
|
|
602
603
|
...(signal === undefined ? {} : { signal }),
|
|
603
604
|
});
|
|
604
605
|
this.assertRecoveryPrincipal(manifest, principal);
|
|
605
606
|
const interrupted = manifest.state === 'applying' || manifest.state === 'rolling_back';
|
|
607
|
+
if (manifest.rootFingerprint !== this.rootFingerprint(workspace)) {
|
|
608
|
+
// A finished journal recorded before the roots changed, for example before an
|
|
609
|
+
// installed DLC folder became a source layer, needs no recovery and must not stop
|
|
610
|
+
// the server. An interrupted one still cannot be replayed against changed roots.
|
|
611
|
+
if (interrupted)
|
|
612
|
+
throw new ServiceError('TRANSACTION_ROOT_CHANGED', 'Workspace root configuration changed after planning');
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
606
615
|
if (!interrupted) {
|
|
607
616
|
try {
|
|
608
617
|
// Ordinary startup must not contend with a live writer just to read terminal journals.
|
|
@@ -842,6 +851,25 @@ export class TransactionManager {
|
|
|
842
851
|
continue;
|
|
843
852
|
}
|
|
844
853
|
let manifest;
|
|
854
|
+
try {
|
|
855
|
+
const recorded = await this.load(workspace, entry.name, {
|
|
856
|
+
headMode: 'none',
|
|
857
|
+
rootBinding: 'ignored',
|
|
858
|
+
});
|
|
859
|
+
if (recorded.rootFingerprint !== this.rootFingerprint(workspace)) {
|
|
860
|
+
// Journals from earlier roots can no longer be applied or rolled back here. Once a
|
|
861
|
+
// finished one expires, remove it so it stops counting against the journal quota.
|
|
862
|
+
if (['planned', 'applied', 'rolled_back', 'failed'].includes(recorded.state) &&
|
|
863
|
+
Date.parse(recorded.expiresAt) <= now) {
|
|
864
|
+
this.removeManifestByteCache(this.manifestCacheKey(workspace, entry.name));
|
|
865
|
+
await rm(directory, { recursive: true, force: true });
|
|
866
|
+
}
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
// Unreadable journals keep their previous handling below.
|
|
872
|
+
}
|
|
845
873
|
try {
|
|
846
874
|
manifest = await this.load(workspace, entry.name, { headMode: 'reconcile' });
|
|
847
875
|
}
|
|
@@ -1056,7 +1084,7 @@ export class TransactionManager {
|
|
|
1056
1084
|
await hooks?.afterProtectedHeadCommit?.();
|
|
1057
1085
|
}
|
|
1058
1086
|
async load(workspace, transactionId, options = {}) {
|
|
1059
|
-
const { headMode = 'verify', signal, cacheBytes = false } = options;
|
|
1087
|
+
const { headMode = 'verify', signal, cacheBytes = false, rootBinding = 'required' } = options;
|
|
1060
1088
|
signal?.throwIfAborted();
|
|
1061
1089
|
let manifestText;
|
|
1062
1090
|
let manifestPath;
|
|
@@ -1110,7 +1138,8 @@ export class TransactionManager {
|
|
|
1110
1138
|
if (manifest.transactionId !== transactionId || manifest.workspaceId !== workspace.id) {
|
|
1111
1139
|
throw new ServiceError('TRANSACTION_WORKSPACE_MISMATCH', 'Transaction manifest identity does not match its workspace or journal directory');
|
|
1112
1140
|
}
|
|
1113
|
-
if (
|
|
1141
|
+
if (rootBinding === 'required' &&
|
|
1142
|
+
manifest.rootFingerprint !== this.rootFingerprint(workspace)) {
|
|
1114
1143
|
throw new ServiceError('TRANSACTION_ROOT_CHANGED', 'Workspace root configuration changed after planning');
|
|
1115
1144
|
}
|
|
1116
1145
|
if (hashCanonical(manifestIntegrityPayload(manifest)) !== manifest.integrityHash) {
|
|
@@ -456,6 +456,12 @@ function overlayToSvg(scene, variant) {
|
|
|
456
456
|
.join('');
|
|
457
457
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${scene.resolution.width}" height="${scene.resolution.height}" viewBox="0 0 ${scene.resolution.width} ${scene.resolution.height}"><defs>${toolText.definitions()}</defs>${overlays}</svg>`;
|
|
458
458
|
}
|
|
459
|
+
/** The preview backdrop; a transparent scenario leaves uncovered pixels fully transparent. */
|
|
460
|
+
function backdrop(scene, viewBox) {
|
|
461
|
+
return scene.scenario.background === 'transparent'
|
|
462
|
+
? ''
|
|
463
|
+
: `<rect ${rectAttributes(viewBox)} fill="#17202a"/>`;
|
|
464
|
+
}
|
|
459
465
|
export function sceneToSvg(scene, variant) {
|
|
460
466
|
const view = viewFor(scene, variant);
|
|
461
467
|
assertRenderDimensions(view.width, view.height, `GUI ${variant} SVG`);
|
|
@@ -470,7 +476,7 @@ export function sceneToSvg(scene, variant) {
|
|
|
470
476
|
.map((element) => renderOverlay(element, variant, toolText))
|
|
471
477
|
.join('');
|
|
472
478
|
const fidelity = escapeXml(canonicalJson(scene.fidelity));
|
|
473
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs
|
|
479
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs>${backdrop(scene, view.viewBox)}${body}${overlays}</svg>`;
|
|
474
480
|
}
|
|
475
481
|
async function cooperativeParts(values, render, signal) {
|
|
476
482
|
const output = [];
|
|
@@ -494,7 +500,7 @@ async function sceneToSvgCooperative(scene, variant, signal) {
|
|
|
494
500
|
const body = (await cooperativeParts(scene.elements, (element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText), signal)) + renderTooltipPreview(scene, toolText);
|
|
495
501
|
const overlays = await cooperativeParts(scene.elements, (element) => renderOverlay(element, variant, toolText), signal);
|
|
496
502
|
const fidelity = escapeXml(canonicalJson(scene.fidelity));
|
|
497
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs
|
|
503
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs>${backdrop(scene, view.viewBox)}${body}${overlays}</svg>`;
|
|
498
504
|
}
|
|
499
505
|
export function hierarchyToSvg(scene) {
|
|
500
506
|
const rows = scene.elements.toSorted((left, right) => left.depth - right.depth || left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
|
|
@@ -459,6 +459,54 @@ function generatedRows(count, random, options) {
|
|
|
459
459
|
};
|
|
460
460
|
});
|
|
461
461
|
}
|
|
462
|
+
/** Sprite tokens read by scripted GUI `image` properties of the window. */
|
|
463
|
+
function scriptedImageTokens(scripted) {
|
|
464
|
+
const tokens = new Set();
|
|
465
|
+
for (const definition of scripted)
|
|
466
|
+
for (const property of definition.propertyDefinitions)
|
|
467
|
+
for (const [attribute, expression] of Object.entries(property.attributes)) {
|
|
468
|
+
const token = expressionToken(expression);
|
|
469
|
+
if (token !== undefined && attribute.toLocaleLowerCase('en-US') === 'image')
|
|
470
|
+
tokens.add(token);
|
|
471
|
+
}
|
|
472
|
+
return tokens;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Resolve the window's scripted localisation exactly under a supplied scenario: a text or
|
|
476
|
+
* sprite token whose `defined_text` branch the scenario's flags, variables and values decide
|
|
477
|
+
* takes that branch. A token the scenario cannot decide is left out, so it still renders as
|
|
478
|
+
* `[dynamic_loc]`, and caller-supplied values always win.
|
|
479
|
+
*/
|
|
480
|
+
export function resolveScenarioScriptedLocalisation(graph, windowName, base, definitions = new ClausewitzEvaluationDefinitions()) {
|
|
481
|
+
if (graph.scriptedLocalisation.length === 0)
|
|
482
|
+
return base;
|
|
483
|
+
const scripted = relatedScriptedGuis(graph, windowName);
|
|
484
|
+
const elements = relevantElements(graph, windowName, scripted);
|
|
485
|
+
const textual = tokensIn(sourceTexts(graph, elements, base.language, scripted)).textual;
|
|
486
|
+
const explicitValues = guiExplicitConditionValues(base);
|
|
487
|
+
const values = { ...base.values };
|
|
488
|
+
const templates = new Map();
|
|
489
|
+
const evaluate = (key, kind) => explicitValues[key] !== undefined
|
|
490
|
+
? undefined
|
|
491
|
+
: scriptedLocalisationValue(graph, key, base.language, kind, explicitValues, base, definitions, []);
|
|
492
|
+
for (const key of textual) {
|
|
493
|
+
const template = evaluate(key, 'text');
|
|
494
|
+
if (template !== undefined)
|
|
495
|
+
templates.set(key, template);
|
|
496
|
+
}
|
|
497
|
+
for (const key of scriptedImageTokens(scripted)) {
|
|
498
|
+
const sprite = evaluate(key, 'image');
|
|
499
|
+
if (sprite !== undefined)
|
|
500
|
+
values[key] = sprite;
|
|
501
|
+
}
|
|
502
|
+
// Branch texts can embed further tokens, including other resolved branches.
|
|
503
|
+
for (let pass = 0; pass < 3; pass += 1)
|
|
504
|
+
for (const [key, template] of templates)
|
|
505
|
+
values[key] = materializeDynamicText(template, { ...explicitValues, ...values });
|
|
506
|
+
return Object.keys(values).length === Object.keys(base.values).length
|
|
507
|
+
? base
|
|
508
|
+
: { ...base, values };
|
|
509
|
+
}
|
|
462
510
|
export function generateGuiPreviewScenarios(graph, windowName, base, options, definitions = new ClausewitzEvaluationDefinitions()) {
|
|
463
511
|
if (!options.enabled)
|
|
464
512
|
return [];
|
|
@@ -145,6 +145,8 @@ const GuiPreviewScenarioBodySchema = z
|
|
|
145
145
|
conditionResults: z.array(ConditionResultSchema).max(10_000).optional(),
|
|
146
146
|
variables: z.record(z.string(), z.number()).default({}),
|
|
147
147
|
flags: z.record(z.string(), z.boolean()).default({}),
|
|
148
|
+
closedFlags: z.boolean().optional(),
|
|
149
|
+
background: z.enum(['scene', 'transparent']).optional(),
|
|
148
150
|
lists: z.record(z.string(), z.array(objectSchema).max(10_000)).default({}),
|
|
149
151
|
localisation: z.record(z.string(), z.string()).default({}),
|
|
150
152
|
values: objectSchema.default({}),
|
|
@@ -21,7 +21,7 @@ import { projectGuiGraphForArtifact } from './inspection-artifact.js';
|
|
|
21
21
|
import { buildGuiScene } from './layout.js';
|
|
22
22
|
import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
|
|
23
23
|
import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
|
|
24
|
-
import { generateGuiPreviewScenarios } from './scenario-generator.js';
|
|
24
|
+
import { generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
|
|
25
25
|
import { parseGeneratedScenarioOptions, parsePreviewScenario } from './scenario.js';
|
|
26
26
|
import { buildGuiSourceGraph } from './source-graph.js';
|
|
27
27
|
import { assertGuiSourcePatchesSafe } from './source-patch.js';
|
|
@@ -1062,16 +1062,19 @@ export class ScriptedGuiStudio {
|
|
|
1062
1062
|
? undefined
|
|
1063
1063
|
: parseGeneratedScenarioOptions(input.generatedScenarios);
|
|
1064
1064
|
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
|
|
1065
|
+
const evaluationDefinitions = ClausewitzEvaluationDefinitions.build(scanned);
|
|
1066
|
+
const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
|
|
1067
|
+
const suppliedScenario = resolveLocalisation(placeholderScenario);
|
|
1065
1068
|
const generatedScenarios = generatedOptions === undefined
|
|
1066
1069
|
? []
|
|
1067
|
-
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions,
|
|
1068
|
-
const scenario = generatedScenarios[0] ??
|
|
1070
|
+
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions);
|
|
1071
|
+
const scenario = generatedScenarios[0] ?? suppliedScenario;
|
|
1069
1072
|
const relatedScenarios = [
|
|
1070
1073
|
...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
|
|
1071
|
-
? [
|
|
1074
|
+
? [suppliedScenario]
|
|
1072
1075
|
: []),
|
|
1073
1076
|
...generatedScenarios.slice(1),
|
|
1074
|
-
...explicitRelatedScenarios,
|
|
1077
|
+
...explicitRelatedScenarios.map(resolveLocalisation),
|
|
1075
1078
|
];
|
|
1076
1079
|
assertUniqueScenarioIds([scenario, ...relatedScenarios]);
|
|
1077
1080
|
const budget = new RenderBudget();
|
|
@@ -1157,16 +1160,19 @@ export class ScriptedGuiStudio {
|
|
|
1157
1160
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1158
1161
|
...explicitRelatedScenarios,
|
|
1159
1162
|
].some(({ tooltip }) => tooltip !== undefined));
|
|
1163
|
+
const evaluationDefinitions = ClausewitzEvaluationDefinitions.build(scanned);
|
|
1164
|
+
const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
|
|
1165
|
+
const suppliedScenario = resolveLocalisation(placeholderScenario);
|
|
1160
1166
|
const generatedScenarios = generatedOptions === undefined
|
|
1161
1167
|
? []
|
|
1162
|
-
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions,
|
|
1163
|
-
const scenario = generatedScenarios[0] ??
|
|
1168
|
+
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, evaluationDefinitions);
|
|
1169
|
+
const scenario = generatedScenarios[0] ?? suppliedScenario;
|
|
1164
1170
|
const relatedScenarios = [
|
|
1165
1171
|
...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
|
|
1166
|
-
? [
|
|
1172
|
+
? [suppliedScenario]
|
|
1167
1173
|
: []),
|
|
1168
1174
|
...generatedScenarios.slice(1),
|
|
1169
|
-
...explicitRelatedScenarios,
|
|
1175
|
+
...explicitRelatedScenarios.map(resolveLocalisation),
|
|
1170
1176
|
];
|
|
1171
1177
|
assertUniqueScenarioIds([scenario, ...relatedScenarios]);
|
|
1172
1178
|
const baselineScenario = requestedBaselineScenario ??
|
|
@@ -4,6 +4,9 @@ import { sha256Bytes } from '../core/canonical.js';
|
|
|
4
4
|
import { assertRenderDimensions } from '../core/render-budget.js';
|
|
5
5
|
import { GuiAssetCatalog } from './assets.js';
|
|
6
6
|
import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_ELEMENTS, GUI_VALIDATION_MAX_ANCESTOR_HOPS, GUI_VALIDATION_MAX_DIAGNOSTICS, GUI_VALIDATION_MAX_PAIR_COMPARISONS, } from './limits.js';
|
|
7
|
+
/** Horizontal offsets from a parent's centre above rounding and up to a few pixels look accidental. */
|
|
8
|
+
const NEAR_CENTRE_TOLERANCE_PX = 0.5;
|
|
9
|
+
const NEAR_CENTRE_MAX_PX = 3;
|
|
7
10
|
const allStates = [
|
|
8
11
|
'normal',
|
|
9
12
|
'hover',
|
|
@@ -435,6 +438,19 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
|
|
|
435
438
|
const visible = scene.elements.filter((element) => element.visible && area(element.rect) > 0);
|
|
436
439
|
const byId = new Map(scene.elements.map((element) => [element.id, element]));
|
|
437
440
|
for (const element of scene.elements) {
|
|
441
|
+
// An element a few pixels from its parent's horizontal centre was almost always meant
|
|
442
|
+
// to be centred; larger offsets are deliberate placement.
|
|
443
|
+
const parent = element.parentId === undefined ? undefined : byId.get(element.parentId);
|
|
444
|
+
if (element.visible &&
|
|
445
|
+
parent?.visible === true &&
|
|
446
|
+
element.unclippedRect.width > 0 &&
|
|
447
|
+
element.unclippedRect.width < parent.unclippedRect.width - 2) {
|
|
448
|
+
const offset = element.unclippedRect.x +
|
|
449
|
+
element.unclippedRect.width / 2 -
|
|
450
|
+
(parent.unclippedRect.x + parent.unclippedRect.width / 2);
|
|
451
|
+
if (Math.abs(offset) > NEAR_CENTRE_TOLERANCE_PX && Math.abs(offset) <= NEAR_CENTRE_MAX_PX)
|
|
452
|
+
diagnostics.push(issue('GUI_NEAR_CENTRE_OFFSET', 'warning', 'layout', `${element.name} sits ${Math.abs(offset).toFixed(1)} px ${offset > 0 ? 'right' : 'left'} of the centre of ${parent.name}; centre it exactly or offset it clearly.`, element, { parent: parent.id, offset }));
|
|
453
|
+
}
|
|
438
454
|
if (element.unclippedRect.width <= 0 || element.unclippedRect.height <= 0) {
|
|
439
455
|
diagnostics.push(issue('GUI_INVALID_SIZE', 'warning', 'layout', `${element.name} has a non-positive ${element.unclippedRect.width}x${element.unclippedRect.height} size.`, element));
|
|
440
456
|
}
|
|
@@ -477,6 +493,12 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
|
|
|
477
493
|
if (left.clickable && right.clickable && ratio > 0.02) {
|
|
478
494
|
diagnostics.push(issue('GUI_CONFLICTING_CLICK_REGIONS', 'error', 'design', `${left.name} and ${right.name} have overlapping click regions (${(ratio * 100).toFixed(1)}%).`, right, { left: left.id, right: right.id, ratio }));
|
|
479
495
|
}
|
|
496
|
+
// Tab pages and alternative panels overlap by design; the game shows one at a time.
|
|
497
|
+
// When the scenario cannot decide which, the preview draws them stacked.
|
|
498
|
+
if (left.depth === right.depth &&
|
|
499
|
+
ratio > 0.2 &&
|
|
500
|
+
(left.visibilityStatus === 'unresolved' || right.visibilityStatus === 'unresolved'))
|
|
501
|
+
diagnostics.push(issue('GUI_UNRESOLVED_VISIBILITY_OVERLAP', 'warning', 'layout', `${left.name} and ${right.name} overlap and the scenario does not decide which is visible, so the preview draws both. Declare the flags or variables their visible triggers read, with closedFlags, to render one of them.`, right, { left: left.id, right: right.id, ratio }));
|
|
480
502
|
if (left.depth === right.depth && ratio > 0.2) {
|
|
481
503
|
diagnostics.push(issue('GUI_VISIBLE_OVERLAP', 'warning', 'layout', `${left.name} and ${right.name} overlap by ${(ratio * 100).toFixed(1)}% of the smaller element.`, right, { left: left.id, right: right.id, ratio }));
|
|
482
504
|
}
|
|
@@ -44,7 +44,7 @@ export const referenceTools = [
|
|
|
44
44
|
{
|
|
45
45
|
name: 'hoi4.script_validate',
|
|
46
46
|
title: 'Check HOI4 script commands and scopes',
|
|
47
|
-
description: 'Check effect or trigger snippet syntax, native command kinds and
|
|
47
|
+
description: 'Check an effect or trigger snippet (source, kind, scope) or a whole mod file (path) for syntax, native command kinds and scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
|
|
48
48
|
inputSchema: scriptValidateRequestSchema,
|
|
49
49
|
outputSchema: strictOperationResultSchema(scriptValidateDataSchema),
|
|
50
50
|
annotations: readOnly,
|
|
@@ -80,12 +80,21 @@ export async function serveNegotiatedStdio(engine, options) {
|
|
|
80
80
|
reportedErrors.add(error);
|
|
81
81
|
options.onerror(error);
|
|
82
82
|
};
|
|
83
|
+
const observer = options.observer;
|
|
84
|
+
if (observer !== undefined) {
|
|
85
|
+
const send = physical.send.bind(physical);
|
|
86
|
+
physical.send = (message) => {
|
|
87
|
+
observer.sent(message);
|
|
88
|
+
return send(message);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
83
91
|
physical.onclose = () => pinned?.onclose();
|
|
84
92
|
physical.onerror = (error) => {
|
|
85
93
|
reportError(error);
|
|
86
94
|
pinned?.onerror(error);
|
|
87
95
|
};
|
|
88
96
|
physical.onmessage = (message) => {
|
|
97
|
+
observer?.received(message);
|
|
89
98
|
if (pinned === undefined) {
|
|
90
99
|
pinned = new PinnedStdioTransport(physical, reportError);
|
|
91
100
|
try {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ends a stdio server that no client can reach. A client normally closes stdin, which the
|
|
3
|
+
* transport turns into a clean exit. Two cases leave stdin open: the launching process
|
|
4
|
+
* (for example a `cmd.exe` shim that a client terminated) dies while the client keeps the
|
|
5
|
+
* pipe, and a client keeps an idle connection it will never use again. The first is detected
|
|
6
|
+
* by polling the parent process; the second by an optional idle limit.
|
|
7
|
+
*/
|
|
8
|
+
const PARENT_POLL_MS = 5_000;
|
|
9
|
+
/** Whether a process exists; a permission refusal still proves it exists. */
|
|
10
|
+
export function processAlive(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return error.code === 'EPERM';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class StdioLifetime {
|
|
20
|
+
#options;
|
|
21
|
+
#open = new Set();
|
|
22
|
+
#lastActivity;
|
|
23
|
+
#timer;
|
|
24
|
+
#stopped = false;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.#options = {
|
|
27
|
+
parentPid: options.parentPid ?? process.ppid,
|
|
28
|
+
isAlive: options.isAlive ?? processAlive,
|
|
29
|
+
now: options.now ?? Date.now,
|
|
30
|
+
pollMs: options.pollMs ?? PARENT_POLL_MS,
|
|
31
|
+
idleExitMinutes: options.idleExitMinutes,
|
|
32
|
+
onExit: options.onExit,
|
|
33
|
+
};
|
|
34
|
+
this.#lastActivity = this.#options.now();
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
this.#timer = setInterval(() => this.check(), this.#options.pollMs);
|
|
38
|
+
// The poll never keeps an otherwise finished server alive.
|
|
39
|
+
this.#timer.unref();
|
|
40
|
+
}
|
|
41
|
+
stop() {
|
|
42
|
+
this.#stopped = true;
|
|
43
|
+
if (this.#timer !== undefined)
|
|
44
|
+
clearInterval(this.#timer);
|
|
45
|
+
}
|
|
46
|
+
/** A client message: a request opens until its response is sent. */
|
|
47
|
+
received(message) {
|
|
48
|
+
this.#lastActivity = this.#options.now();
|
|
49
|
+
const id = requestId(message, 'method');
|
|
50
|
+
if (id !== undefined)
|
|
51
|
+
this.#open.add(id);
|
|
52
|
+
}
|
|
53
|
+
/** A server message: a response closes its request. */
|
|
54
|
+
sent(message) {
|
|
55
|
+
this.#lastActivity = this.#options.now();
|
|
56
|
+
const id = requestId(message, 'result') ?? requestId(message, 'error');
|
|
57
|
+
if (id !== undefined)
|
|
58
|
+
this.#open.delete(id);
|
|
59
|
+
}
|
|
60
|
+
check() {
|
|
61
|
+
if (this.#stopped)
|
|
62
|
+
return;
|
|
63
|
+
const { parentPid, isAlive, idleExitMinutes, now, onExit } = this.#options;
|
|
64
|
+
// A parent pid of 0 or 1 means the server was already reparented at start.
|
|
65
|
+
if (parentPid > 1 && !isAlive(parentPid)) {
|
|
66
|
+
this.stop();
|
|
67
|
+
onExit('parent_exited');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (idleExitMinutes > 0 &&
|
|
71
|
+
this.#open.size === 0 &&
|
|
72
|
+
now() - this.#lastActivity > idleExitMinutes * 60_000) {
|
|
73
|
+
this.stop();
|
|
74
|
+
onExit('idle');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function requestId(message, field) {
|
|
79
|
+
if (typeof message !== 'object' || message === null || !(field in message))
|
|
80
|
+
return undefined;
|
|
81
|
+
const id = message.id;
|
|
82
|
+
return typeof id === 'string' || typeof id === 'number' ? id : undefined;
|
|
83
|
+
}
|