hoi4-agent-tools 3.8.1 → 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.
- package/CHANGELOG.md +24 -1
- package/README.md +1 -1
- package/dist/bin/http.js +2 -0
- package/dist/bin/setup.js +5 -2
- package/dist/bin/stdio.js +29 -1
- package/dist/hoi4_agent_tools/core/configuration.js +8 -0
- package/dist/hoi4_agent_tools/core/engine.js +27 -8
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- 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/server-registry.js +138 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/source/cache.js +89 -20
- package/dist/hoi4_agent_tools/event/service.js +7 -0
- package/dist/hoi4_agent_tools/gui/renderer.js +8 -2
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +56 -0
- package/dist/hoi4_agent_tools/gui/scenario.js +2 -0
- package/dist/hoi4_agent_tools/gui/studio.js +31 -12
- package/dist/hoi4_agent_tools/gui/validators.js +22 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +5 -2
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +9 -0
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +79 -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/schemas/technology.js +4 -2
- package/dist/hoi4_agent_tools/technology/service.js +7 -0
- 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 +11 -0
- package/docs/tools.md +1 -1
- package/npm-shrinkwrap.json +12 -8
- 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,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 3.10.0 - 2026-10-06
|
|
4
|
+
|
|
5
|
+
- Rebuild a workspace index after an edit from the previous index, parsing and indexing only changed files, and keep the latest scan of each pattern set and each GUI graph scope instead of one per workspace. A repeated GUI preview of a large, actively edited mod took 64 s instead of 244 s.
|
|
6
|
+
- Admit parsed documents to a full cache only on their second miss and only in place of entries never read, so scans larger than the cache no longer serialize and evict every document; build scripted-localisation definitions only for windows that show scripted localisation.
|
|
7
|
+
- List every server process sharing a server state root, with its memory, last client activity and execution limits, in the `hoi4-agent://server/status` resource of local stdio connections.
|
|
8
|
+
- Refuse an `hoi4.event_compare` or `hoi4.tech_compare` call without references before scanning when no earlier revision is cached, instead of after a full scan.
|
|
9
|
+
- Report a `hoi4.tech_render` folder view without `folderId` as a readable argument error instead of a raw validation dump.
|
|
10
|
+
- Ship `proxy-addr` 2.0.8, which fixes IP spoofing through IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h); the HTTP server sets no `trust proxy`, so earlier releases did not reach the affected code.
|
|
11
|
+
|
|
12
|
+
## 3.9.0 - 2026-10-06
|
|
13
|
+
|
|
14
|
+
- Exit a stdio server whose launching process has exited, and add `stdioIdleExitMinutes` so clients that keep idle per-subagent connections no longer accumulate servers.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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`).
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- 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]`.
|
|
24
|
+
- Warn with `GUI_UNRESOLVED_VISIBILITY_OVERLAP` when overlapping elements are drawn together because the scenario does not decide which one is visible.
|
|
25
|
+
- Warn with `GUI_NEAR_CENTRE_OFFSET` when an element sits a few pixels from its parent's horizontal centre.
|
|
26
|
+
- Add `background: "transparent"` to GUI scenarios for renders without the preview backdrop.
|
|
4
27
|
|
|
5
28
|
## 3.8.1 - 2026-10-05
|
|
6
29
|
|
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/http.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
|
|
3
|
+
import { ServerRegistry } from '../hoi4_agent_tools/core/server-registry.js';
|
|
3
4
|
import { createMcpServer } from '../hoi4_agent_tools/mcp/server/create.js';
|
|
4
5
|
import { startHttpServer } from '../hoi4_agent_tools/mcp/transports/http.js';
|
|
5
6
|
import { createEngine } from '../hoi4_agent_tools/runtime.js';
|
|
6
7
|
async function main() {
|
|
7
8
|
const engine = await createEngine();
|
|
8
9
|
const handle = await startHttpServer(engine, engine.resolver.config(), createMcpServer);
|
|
10
|
+
new ServerRegistry(engine.resolver, 'http').start();
|
|
9
11
|
const shutdown = async () => {
|
|
10
12
|
await handle.close();
|
|
11
13
|
process.exit(0);
|
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,23 @@
|
|
|
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 { ServerRegistry } from '../hoi4_agent_tools/core/server-registry.js';
|
|
6
|
+
import { StdioLifetime } from '../hoi4_agent_tools/mcp/transports/stdio-lifetime.js';
|
|
5
7
|
import { createEngine } from '../hoi4_agent_tools/runtime.js';
|
|
8
|
+
/**
|
|
9
|
+
* A client registration can set its own idle limit, because the server configuration is shared
|
|
10
|
+
* by every client: clients that keep idle per-subagent connections opt in without affecting a
|
|
11
|
+
* long-lived interactive session.
|
|
12
|
+
*/
|
|
13
|
+
function idleExitMinutes(configured) {
|
|
14
|
+
const override = process.env.HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES;
|
|
15
|
+
if (override === undefined || override.trim() === '')
|
|
16
|
+
return configured;
|
|
17
|
+
const minutes = Number(override);
|
|
18
|
+
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 10_080)
|
|
19
|
+
throw new Error('HOI4_AGENT_STDIO_IDLE_EXIT_MINUTES must be a whole number from 0 to 10080');
|
|
20
|
+
return minutes;
|
|
21
|
+
}
|
|
6
22
|
async function main() {
|
|
7
23
|
const engine = await createEngine();
|
|
8
24
|
const chaosxTools = process.env.HOI4_AGENT_TOOLS_CHAOSX === '1'
|
|
@@ -20,8 +36,17 @@ async function main() {
|
|
|
20
36
|
message: error.message,
|
|
21
37
|
})}\n`);
|
|
22
38
|
};
|
|
23
|
-
|
|
39
|
+
let closeTransport = () => Promise.resolve();
|
|
40
|
+
const lifetime = new StdioLifetime({
|
|
41
|
+
idleExitMinutes: idleExitMinutes(engine.resolver.config().stdioIdleExitMinutes),
|
|
42
|
+
onExit: (reason) => {
|
|
43
|
+
process.stderr.write(`${canonicalJson({ level: 'info', event: 'stdio_exit', reason })}\n`);
|
|
44
|
+
void closeTransport().finally(() => setTimeout(() => process.exit(0), 250).unref());
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const transport = await serveNegotiatedStdio(engine, {
|
|
24
48
|
onerror,
|
|
49
|
+
observer: lifetime,
|
|
25
50
|
onFailure: (error) => {
|
|
26
51
|
process.exitCode = 1;
|
|
27
52
|
process.stderr.write(`${canonicalJson({
|
|
@@ -37,6 +62,9 @@ async function main() {
|
|
|
37
62
|
registerLegacy: (server) => chaosxTools.registerChaosxTools(server, engine, {}),
|
|
38
63
|
}),
|
|
39
64
|
});
|
|
65
|
+
closeTransport = () => transport.close();
|
|
66
|
+
lifetime.start();
|
|
67
|
+
new ServerRegistry(engine.resolver, 'stdio', () => lifetime.lastActivity()).start();
|
|
40
68
|
}
|
|
41
69
|
main().catch((error) => {
|
|
42
70
|
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()
|
|
@@ -9,6 +9,12 @@ import { TransactionManager, transactionRootFingerprint } from './transactions.j
|
|
|
9
9
|
import { RequestScheduler } from './request-scheduler.js';
|
|
10
10
|
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
11
11
|
import { JobStore } from './job-store.js';
|
|
12
|
+
/**
|
|
13
|
+
* Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
|
|
14
|
+
* preview scans layouts, linked definitions and localisation), so a single slot made every
|
|
15
|
+
* repeat call rescan and reindex each set.
|
|
16
|
+
*/
|
|
17
|
+
const SCAN_CACHE_ENTRIES_PER_WORKSPACE = 4;
|
|
12
18
|
const RECOVERY_TTL_SECONDS = 3_600;
|
|
13
19
|
const RECOVERY_MAX_BYTES = 536_870_912;
|
|
14
20
|
const RECOVERY_MAX_RECORDS = 128;
|
|
@@ -167,9 +173,6 @@ export class CoreEngine {
|
|
|
167
173
|
})}`;
|
|
168
174
|
let flight = this.#scanFlights.get(requestKey);
|
|
169
175
|
if (flight === undefined) {
|
|
170
|
-
for (const key of this.#scanCache.keys())
|
|
171
|
-
if (key.startsWith(`${workspaceId}:`) && !key.startsWith(`${requestKey}:`))
|
|
172
|
-
this.#scanCache.delete(key);
|
|
173
176
|
const controller = new AbortController();
|
|
174
177
|
const promise = (async () => {
|
|
175
178
|
return this.withScanAdmission(controller.signal, async () => {
|
|
@@ -193,8 +196,11 @@ export class CoreEngine {
|
|
|
193
196
|
})));
|
|
194
197
|
const cacheKey = `${requestKey}:${revision}`;
|
|
195
198
|
const cached = this.#scanCache.get(cacheKey);
|
|
196
|
-
if (cached !== undefined)
|
|
199
|
+
if (cached !== undefined) {
|
|
200
|
+
this.#scanCache.delete(cacheKey);
|
|
201
|
+
this.#scanCache.set(cacheKey, cached);
|
|
197
202
|
return cached;
|
|
203
|
+
}
|
|
198
204
|
const persistentAnalysisCache = await this.persistentAnalysisCache;
|
|
199
205
|
const analysisScope = {
|
|
200
206
|
workspaceIdentity: workspace.workspaceIdentity,
|
|
@@ -202,7 +208,12 @@ export class CoreEngine {
|
|
|
202
208
|
principal: principal ?? null,
|
|
203
209
|
};
|
|
204
210
|
await persistentAnalysisCache?.hydrate(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
|
|
205
|
-
|
|
211
|
+
// The previous revision of the same request still holds every unchanged file's
|
|
212
|
+
// records, so only edited files are parsed and indexed again.
|
|
213
|
+
const previous = [...this.#scanCache]
|
|
214
|
+
.filter(([key]) => key.startsWith(`${requestKey}:`))
|
|
215
|
+
.at(-1)?.[1].index;
|
|
216
|
+
const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments, previous);
|
|
206
217
|
await persistentAnalysisCache?.persist(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
|
|
207
218
|
const snapshot = {
|
|
208
219
|
workspaceId,
|
|
@@ -215,11 +226,18 @@ export class CoreEngine {
|
|
|
215
226
|
diagnostics: index.diagnostics,
|
|
216
227
|
};
|
|
217
228
|
if ((this.#scanGenerations.get(workspaceId) ?? 0) === generation) {
|
|
229
|
+
// Replace this request's earlier revision and drop earlier generations; keep the
|
|
230
|
+
// most recent other requests of this workspace.
|
|
218
231
|
for (const key of this.#scanCache.keys()) {
|
|
219
|
-
if (key.startsWith(`${
|
|
232
|
+
if (key.startsWith(`${requestKey}:`) ||
|
|
233
|
+
(key.startsWith(`${workspaceId}:`) &&
|
|
234
|
+
!key.startsWith(`${workspaceId}:${generation}:`)))
|
|
220
235
|
this.#scanCache.delete(key);
|
|
221
236
|
}
|
|
222
237
|
this.#scanCache.set(cacheKey, snapshot);
|
|
238
|
+
const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
|
|
239
|
+
for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
|
|
240
|
+
this.#scanCache.delete(key);
|
|
223
241
|
}
|
|
224
242
|
return snapshot;
|
|
225
243
|
});
|
|
@@ -384,7 +402,8 @@ export class CoreEngine {
|
|
|
384
402
|
request.run();
|
|
385
403
|
}
|
|
386
404
|
}
|
|
387
|
-
|
|
388
|
-
|
|
405
|
+
/** `previous` supplies unchanged files' records from an earlier index of the same sources. */
|
|
406
|
+
indexFiles(files, previous) {
|
|
407
|
+
return SymbolIndex.build(files, this.indexSegments, previous);
|
|
389
408
|
}
|
|
390
409
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { compareCodeUnits } from './canonical.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { sortDiagnostics } from './diagnostics.js';
|
|
4
|
-
import { indexSegmentAddress } from './index-segments.js';
|
|
4
|
+
import { indexSegmentAddress, } from './index-segments.js';
|
|
5
5
|
import { parseClausewitz, parseLocalisation, assignments, childBlocks, firstScalar, locationFor, sourcePartialLimitDiagnostics, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, nodeLocation, } from './source/index.js';
|
|
6
6
|
const spriteDefinitionKeys = new Set([
|
|
7
7
|
'spritetype',
|
|
@@ -261,6 +261,11 @@ export class SymbolIndex {
|
|
|
261
261
|
files = new Map();
|
|
262
262
|
#active = new Map();
|
|
263
263
|
#definitionFiles = new Set();
|
|
264
|
+
/**
|
|
265
|
+
* Where each completely indexed file's records sit in this index, by segment address, so a
|
|
266
|
+
* rebuild after an edit reuses unchanged files instead of parsing and indexing them again.
|
|
267
|
+
*/
|
|
268
|
+
#fileRanges = new Map();
|
|
264
269
|
#indexNestingBlocked = new Set();
|
|
265
270
|
#skippedSourcePaths = new Set();
|
|
266
271
|
#skippedPossibleSymbolKinds = new Set();
|
|
@@ -271,7 +276,7 @@ export class SymbolIndex {
|
|
|
271
276
|
#referenceLimitReported = false;
|
|
272
277
|
#diagnosticsTruncated = false;
|
|
273
278
|
#indexTableBlocked = new Set();
|
|
274
|
-
static build(files, segments) {
|
|
279
|
+
static build(files, segments, previous) {
|
|
275
280
|
const index = new SymbolIndex();
|
|
276
281
|
const definitionSelection = selectedDefinitionFiles(files);
|
|
277
282
|
for (const displayPath of definitionSelection.selected)
|
|
@@ -281,13 +286,13 @@ export class SymbolIndex {
|
|
|
281
286
|
}
|
|
282
287
|
for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
283
288
|
index.files.set(file.displayPath, file);
|
|
284
|
-
index.indexFileWithSegments(file, segments);
|
|
289
|
+
index.indexFileWithSegments(file, segments, previous);
|
|
285
290
|
}
|
|
286
291
|
index.finalize();
|
|
287
292
|
return index;
|
|
288
293
|
}
|
|
289
294
|
/** Yield between source files so protocol cancellation and progress remain responsive. */
|
|
290
|
-
static async buildAsync(files, signal, segments) {
|
|
295
|
+
static async buildAsync(files, signal, segments, previous) {
|
|
291
296
|
signal?.throwIfAborted();
|
|
292
297
|
const index = new SymbolIndex();
|
|
293
298
|
const definitionSelection = selectedDefinitionFiles(files);
|
|
@@ -300,7 +305,7 @@ export class SymbolIndex {
|
|
|
300
305
|
for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
301
306
|
signal?.throwIfAborted();
|
|
302
307
|
index.files.set(file.displayPath, file);
|
|
303
|
-
index.indexFileWithSegments(file, segments);
|
|
308
|
+
index.indexFileWithSegments(file, segments, previous);
|
|
304
309
|
if (performance.now() - yieldedAt >= 20) {
|
|
305
310
|
await new Promise((resolve) => setImmediate(resolve));
|
|
306
311
|
signal?.throwIfAborted();
|
|
@@ -437,31 +442,50 @@ export class SymbolIndex {
|
|
|
437
442
|
for (const diagnostic of diagnostics)
|
|
438
443
|
this.addDiagnostic(diagnostic);
|
|
439
444
|
}
|
|
440
|
-
|
|
441
|
-
|
|
445
|
+
/** The records one completely indexed file contributed to this index, if it was recorded. */
|
|
446
|
+
#segmentAt(address) {
|
|
447
|
+
const range = this.#fileRanges.get(address);
|
|
448
|
+
if (range === undefined)
|
|
449
|
+
return undefined;
|
|
450
|
+
return {
|
|
451
|
+
symbols: this.symbols.slice(...range.symbols),
|
|
452
|
+
references: this.references.slice(...range.references),
|
|
453
|
+
diagnostics: this.diagnostics.slice(...range.diagnostics),
|
|
454
|
+
complete: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
indexFileWithSegments(file, cache, previous) {
|
|
458
|
+
if (cache === undefined && previous === undefined) {
|
|
442
459
|
this.indexFile(file);
|
|
443
460
|
return;
|
|
444
461
|
}
|
|
445
462
|
const address = indexSegmentAddress(file, this.#definitionFiles.has(file.displayPath));
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
463
|
+
const symbols = this.symbols.length;
|
|
464
|
+
const references = this.references.length;
|
|
465
|
+
const diagnostics = this.diagnostics.length;
|
|
466
|
+
const record = () => this.#fileRanges.set(address, {
|
|
467
|
+
symbols: [symbols, this.symbols.length],
|
|
468
|
+
references: [references, this.references.length],
|
|
469
|
+
diagnostics: [diagnostics, this.diagnostics.length],
|
|
470
|
+
});
|
|
471
|
+
const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
|
|
472
|
+
if (reused?.complete === true &&
|
|
473
|
+
this.symbols.length + reused.symbols.length <= INDEX_RECORD_LIMIT &&
|
|
474
|
+
this.references.length + reused.references.length <= INDEX_RECORD_LIMIT &&
|
|
475
|
+
this.diagnostics.length + reused.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
|
|
451
476
|
this.#currentFileShadowed = file.shadowedBy !== undefined;
|
|
452
|
-
for (const symbol of
|
|
477
|
+
for (const symbol of reused.symbols)
|
|
453
478
|
this.addSymbol(symbol);
|
|
454
|
-
for (const reference of
|
|
479
|
+
for (const reference of reused.references)
|
|
455
480
|
this.addReference(reference);
|
|
456
|
-
this.addDiagnostics(
|
|
481
|
+
this.addDiagnostics(reused.diagnostics);
|
|
482
|
+
if (!this.#diagnosticsTruncated)
|
|
483
|
+
record();
|
|
457
484
|
return;
|
|
458
485
|
}
|
|
459
|
-
const symbols = this.symbols.length;
|
|
460
|
-
const references = this.references.length;
|
|
461
|
-
const diagnostics = this.diagnostics.length;
|
|
462
486
|
this.indexFile(file);
|
|
463
487
|
// Incomplete files and aggregate ceilings retain the full builder's exact diagnostic
|
|
464
|
-
// ordering and coverage bookkeeping. They are never
|
|
488
|
+
// ordering and coverage bookkeeping. They are never reused as complete segments.
|
|
465
489
|
if (this.#indexTableBlocked.has(file.displayPath) ||
|
|
466
490
|
this.#skippedSourcePaths.has(file.displayPath) ||
|
|
467
491
|
this.#indexNestingBlocked.has(file.displayPath) ||
|
|
@@ -469,7 +493,8 @@ export class SymbolIndex {
|
|
|
469
493
|
this.#referenceLimitReported ||
|
|
470
494
|
this.#diagnosticsTruncated)
|
|
471
495
|
return;
|
|
472
|
-
|
|
496
|
+
record();
|
|
497
|
+
cache?.put(address, {
|
|
473
498
|
symbols: this.symbols.slice(symbols),
|
|
474
499
|
references: this.references.slice(references),
|
|
475
500
|
diagnostics: this.diagnostics.slice(diagnostics),
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { unlinkSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod/v4';
|
|
5
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
6
|
+
/**
|
|
7
|
+
* Every server process sharing a server state root records a small heartbeat, so one status
|
|
8
|
+
* read shows how many servers and workers a machine runs and how much memory each holds.
|
|
9
|
+
*/
|
|
10
|
+
const HEARTBEAT_MS = 60_000;
|
|
11
|
+
/** A record this old without an update belongs to a process that is blocked or gone. */
|
|
12
|
+
const STALE_MS = 5 * HEARTBEAT_MS;
|
|
13
|
+
const MAX_RECORDS = 512;
|
|
14
|
+
const recordSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
pid: z.number().int().positive(),
|
|
17
|
+
version: z.string().max(64),
|
|
18
|
+
transport: z.enum(['stdio', 'http']),
|
|
19
|
+
startedAt: z.string().max(64),
|
|
20
|
+
updatedAt: z.string().max(64),
|
|
21
|
+
lastActivityAt: z.string().max(64).optional(),
|
|
22
|
+
rssBytes: z.number().int().min(0),
|
|
23
|
+
heapUsedBytes: z.number().int().min(0),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
function directory(resolver) {
|
|
27
|
+
const state = resolver.serverState();
|
|
28
|
+
return state === undefined ? undefined : path.join(state.root, 'servers');
|
|
29
|
+
}
|
|
30
|
+
/** Whether a process exists; a permission refusal still proves it exists. */
|
|
31
|
+
export function processAlive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return error.code === 'EPERM';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The registry of this server process, or a status-only one when no server started it. */
|
|
41
|
+
export function serverRegistry(resolver) {
|
|
42
|
+
return ServerRegistry.active ?? new ServerRegistry(resolver, 'stdio');
|
|
43
|
+
}
|
|
44
|
+
export class ServerRegistry {
|
|
45
|
+
resolver;
|
|
46
|
+
transport;
|
|
47
|
+
lastActivity;
|
|
48
|
+
/** The registry whose heartbeats this process writes. */
|
|
49
|
+
static active;
|
|
50
|
+
#startedAt = new Date().toISOString();
|
|
51
|
+
#timer;
|
|
52
|
+
constructor(resolver, transport, lastActivity = () => undefined) {
|
|
53
|
+
this.resolver = resolver;
|
|
54
|
+
this.transport = transport;
|
|
55
|
+
this.lastActivity = lastActivity;
|
|
56
|
+
}
|
|
57
|
+
current() {
|
|
58
|
+
const memory = process.memoryUsage();
|
|
59
|
+
const activity = this.lastActivity();
|
|
60
|
+
return {
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
version: PACKAGE_VERSION,
|
|
63
|
+
transport: this.transport,
|
|
64
|
+
startedAt: this.#startedAt,
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
...(activity === undefined ? {} : { lastActivityAt: new Date(activity).toISOString() }),
|
|
67
|
+
rssBytes: memory.rss,
|
|
68
|
+
heapUsedBytes: memory.heapUsed,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Start heartbeats; the record is removed when the process exits normally. */
|
|
72
|
+
start() {
|
|
73
|
+
ServerRegistry.active = this;
|
|
74
|
+
const root = directory(this.resolver);
|
|
75
|
+
if (root === undefined)
|
|
76
|
+
return;
|
|
77
|
+
const file = path.join(root, `${process.pid}.json`);
|
|
78
|
+
const beat = () => void mkdir(root, { recursive: true })
|
|
79
|
+
.then(async () => {
|
|
80
|
+
const temporary = `${file}.${Date.now()}.tmp`;
|
|
81
|
+
await writeFile(temporary, `${JSON.stringify(this.current())}\n`);
|
|
82
|
+
await rename(temporary, file);
|
|
83
|
+
})
|
|
84
|
+
.catch(() => undefined);
|
|
85
|
+
beat();
|
|
86
|
+
this.#timer = setInterval(beat, HEARTBEAT_MS);
|
|
87
|
+
this.#timer.unref();
|
|
88
|
+
process.once('exit', () => {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A record left behind is removed by the next status read once the pid is gone.
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async status() {
|
|
98
|
+
const current = this.current();
|
|
99
|
+
const config = this.resolver.config();
|
|
100
|
+
const limits = {
|
|
101
|
+
maxConcurrentTools: config.maxConcurrentTools,
|
|
102
|
+
maxSharedTools: config.maxSharedTools,
|
|
103
|
+
jobDeadlineSeconds: config.jobDeadlineSeconds,
|
|
104
|
+
jobCancelGraceSeconds: config.jobCancelGraceSeconds,
|
|
105
|
+
stdioIdleExitMinutes: config.stdioIdleExitMinutes,
|
|
106
|
+
jobWorkerMaxHeapMiB: config.jobWorkerMaxHeapMiB,
|
|
107
|
+
};
|
|
108
|
+
const root = directory(this.resolver);
|
|
109
|
+
const servers = [];
|
|
110
|
+
let removed = 0;
|
|
111
|
+
if (root !== undefined) {
|
|
112
|
+
const names = (await readdir(root).catch(() => []))
|
|
113
|
+
.filter((name) => /^\d+\.json$/u.test(name))
|
|
114
|
+
.slice(0, MAX_RECORDS);
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
const file = path.join(root, name);
|
|
117
|
+
const parsed = recordSchema.safeParse(await readFile(file, 'utf8')
|
|
118
|
+
.then((text) => JSON.parse(text))
|
|
119
|
+
.catch(() => undefined));
|
|
120
|
+
if (!parsed.success)
|
|
121
|
+
continue;
|
|
122
|
+
const record = parsed.data;
|
|
123
|
+
if (record.pid === process.pid)
|
|
124
|
+
continue;
|
|
125
|
+
if (!processAlive(record.pid)) {
|
|
126
|
+
removed += 1;
|
|
127
|
+
await unlink(file).catch(() => undefined);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const age = Date.now() - Date.parse(record.updatedAt);
|
|
131
|
+
servers.push({ ...record, state: age > STALE_MS ? 'stale' : 'live' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
servers.push({ ...current, state: 'live' });
|
|
135
|
+
servers.sort((left, right) => right.rssBytes - left.rssBytes || left.pid - right.pid);
|
|
136
|
+
return { current, servers, removed, limits };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical.js';
|
|
2
|
+
import { serverRegistry } from './server-registry.js';
|
|
3
|
+
/** A fixed, listed resource: every server process sharing this server state and its memory. */
|
|
4
|
+
export const serverStatusResource = {
|
|
5
|
+
uri: 'hoi4-agent://server/status',
|
|
6
|
+
name: 'server-status',
|
|
7
|
+
title: 'HOI4 Agent Tools server status',
|
|
8
|
+
description: 'Live server processes sharing this server state, with memory, last client activity and execution limits.',
|
|
9
|
+
mimeType: 'application/json',
|
|
10
|
+
};
|
|
11
|
+
export async function readServerStatus(engine) {
|
|
12
|
+
const status = await serverRegistry(engine.resolver).status();
|
|
13
|
+
return {
|
|
14
|
+
contents: [
|
|
15
|
+
{
|
|
16
|
+
uri: serverStatusResource.uri,
|
|
17
|
+
mimeType: serverStatusResource.mimeType,
|
|
18
|
+
text: `${canonicalJson(status)}\n`,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|