@juspay/neurolink 12.1.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/agent/agentToolRegistrar.d.ts +30 -0
  3. package/dist/agent/agentToolRegistrar.js +72 -18
  4. package/dist/agent/backgroundCommands.d.ts +110 -0
  5. package/dist/agent/backgroundCommands.js +914 -0
  6. package/dist/agent/backgroundDelegation.d.ts +87 -0
  7. package/dist/agent/backgroundDelegation.js +753 -0
  8. package/dist/agent/gitTools.d.ts +43 -0
  9. package/dist/agent/gitTools.js +618 -0
  10. package/dist/agent/taskChecklist.d.ts +58 -0
  11. package/dist/agent/taskChecklist.js +322 -0
  12. package/dist/artifacts/artifactBanking.d.ts +57 -0
  13. package/dist/artifacts/artifactBanking.js +123 -0
  14. package/dist/artifacts/artifactStore.d.ts +36 -8
  15. package/dist/artifacts/artifactStore.js +164 -13
  16. package/dist/browser/neurolink.min.js +442 -414
  17. package/dist/neurolink.d.ts +294 -3
  18. package/dist/neurolink.js +447 -4
  19. package/dist/types/artifact.d.ts +54 -0
  20. package/dist/types/backgroundCommand.d.ts +174 -0
  21. package/dist/types/backgroundCommand.js +22 -0
  22. package/dist/types/delegation.d.ts +178 -0
  23. package/dist/types/delegation.js +18 -0
  24. package/dist/types/gitTools.d.ts +69 -0
  25. package/dist/types/gitTools.js +22 -0
  26. package/dist/types/index.d.ts +5 -0
  27. package/dist/types/index.js +8 -0
  28. package/dist/types/pathSandbox.d.ts +23 -0
  29. package/dist/types/pathSandbox.js +12 -0
  30. package/dist/types/tasks.d.ts +85 -0
  31. package/dist/types/tasks.js +14 -0
  32. package/dist/types/tools.d.ts +11 -0
  33. package/dist/utils/pathSandbox.d.ts +49 -0
  34. package/dist/utils/pathSandbox.js +127 -0
  35. package/package.json +5 -1
package/dist/neurolink.js CHANGED
@@ -48,6 +48,7 @@ import { EnhancedToolDiscovery } from "./mcp/enhancedToolDiscovery.js";
48
48
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
49
49
  import { McpOutputNormalizer, DEFAULT_MAX_MCP_OUTPUT_BYTES, DEFAULT_WARN_MCP_OUTPUT_BYTES, } from "./mcp/mcpOutputNormalizer.js";
50
50
  import { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
51
+ import { bankArtifact as bankArtifactPayload, readArtifact as readBankedArtifact, } from "./artifacts/artifactBanking.js";
51
52
  import { ToolRouter } from "./mcp/routing/index.js";
52
53
  // Import direct tools server for automatic registration
53
54
  import { directToolsServer } from "./mcp/servers/agent/directToolsServer.js";
@@ -66,6 +67,10 @@ import { AnalyticsService, calculateAdvancedCost, parseAnalyticsQualityScore, }
66
67
  import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
67
68
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
68
69
  import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
70
+ import { clearChecklistState, createChecklistTools, getChecklistState, resolveChecklistSessionId, } from "./agent/taskChecklist.js";
71
+ import { cancelDelegates as cancelBackgroundDelegates, collectDelegates as collectBackgroundDelegates, configureDelegation, createDelegationTools, spawnDelegate as spawnBackgroundDelegate, } from "./agent/backgroundDelegation.js";
72
+ import { awaitBackgroundCommand as awaitCommand, createBackgroundCommandTools, getBackgroundCommandStatus as getCommandStatus, killAllBackgroundCommands as killAllCommands, killBackgroundCommand as killCommand, readBackgroundCommandOutput as readCommandOutput, setBackgroundCommandPolicy as setCommandPolicy, startBackgroundCommand as startCommand, } from "./agent/backgroundCommands.js";
73
+ import { configureGitTools, createGitTools, runGitCommand as runGitArgs, } from "./agent/gitTools.js";
69
74
  import { TaskManager } from "./tasks/taskManager.js";
70
75
  import { createTaskTools } from "./tasks/tools/taskTools.js";
71
76
  import { ATTR, spanJsonAttribute } from "./telemetry/attributes.js";
@@ -792,6 +797,28 @@ export class NeuroLink {
792
797
  * tool — gates the per-turn delegation scope in generate().
793
798
  */
794
799
  hasAgentTools = false;
800
+ /**
801
+ * Tools registered with `cacheable: false` — their results are never served
802
+ * from the tool-result cache. A tool whose answer depends on live state (a
803
+ * checklist, a work queue) would otherwise replay its first answer for the
804
+ * whole TTL to every caller passing the same arguments.
805
+ */
806
+ uncacheableTools = new Set();
807
+ /** Set once registerTaskTools() has registered the checklist toolset. */
808
+ hasTaskChecklistTools = false;
809
+ /** Set once registerDelegationTools() has registered the delegation toolset. */
810
+ hasBackgroundDelegationTools = false;
811
+ /** Set once registerBackgroundCommandTools() has registered the command toolset. */
812
+ hasBackgroundCommandTools = false;
813
+ /** Set once registerGitTools() has registered the read-only git toolset. */
814
+ hasGitTools = false;
815
+ /**
816
+ * Set once `retrieve_context` has been registered. The constructor skips
817
+ * registration when neither Redis nor an artifact store exists; banking can
818
+ * create a store later, and this keeps that second attempt from
819
+ * re-registering the tool on instances that already have it.
820
+ */
821
+ retrieveContextRegistered = false;
795
822
  /**
796
823
  * Creates a new NeuroLink instance for AI text generation with MCP tool integration.
797
824
  *
@@ -953,7 +980,7 @@ export class NeuroLink {
953
980
  if (this._taskManagerConfig) {
954
981
  this._taskManager = new TaskManager(this, this._taskManagerConfig);
955
982
  this._taskManager.setEmitter(this.emitter);
956
- this.registerTaskTools(this._taskManager);
983
+ this.registerSchedulerTaskTools(this._taskManager);
957
984
  }
958
985
  }
959
986
  /**
@@ -966,7 +993,7 @@ export class NeuroLink {
966
993
  if (!this._taskManager) {
967
994
  this._taskManager = new TaskManager(this, this._taskManagerConfig);
968
995
  this._taskManager.setEmitter(this.emitter);
969
- this.registerTaskTools(this._taskManager);
996
+ this.registerSchedulerTaskTools(this._taskManager);
970
997
  }
971
998
  return this._taskManager;
972
999
  }
@@ -1266,11 +1293,14 @@ export class NeuroLink {
1266
1293
  });
1267
1294
  }
1268
1295
  /**
1269
- * Register task management tools bound to a TaskManager instance.
1296
+ * Register SCHEDULER task tools bound to a TaskManager instance (task_create,
1297
+ * task_list, … — scheduled/self-running jobs). Distinct from the agent task
1298
+ * CHECKLIST (registerTaskTools() / tasks_create): different tools, different
1299
+ * state, different purpose.
1270
1300
  * Follows the same factory + registry pattern as registerFileTools().
1271
1301
  * Called when TaskManager is created (eagerly or lazily via the `tasks` getter).
1272
1302
  */
1273
- registerTaskTools(manager) {
1303
+ registerSchedulerTaskTools(manager) {
1274
1304
  const taskTools = createTaskTools(manager);
1275
1305
  for (const [toolName, toolDef] of Object.entries(taskTools)) {
1276
1306
  const toolId = `direct.${toolName}`;
@@ -1331,6 +1361,9 @@ export class NeuroLink {
1331
1361
  * Only registered when Redis conversation memory is active.
1332
1362
  */
1333
1363
  registerMemoryRetrievalTools() {
1364
+ if (this.retrieveContextRegistered) {
1365
+ return;
1366
+ }
1334
1367
  // Check if conversation memory is configured
1335
1368
  // Memory retrieval tool requires Redis (getSessionRaw) but registration
1336
1369
  // is deferred — the execute handler checks at runtime whether the actual
@@ -1379,6 +1412,7 @@ export class NeuroLink {
1379
1412
  return await withTimeout(tools.retrieve_context.execute(params, { toolCallId: "memory-retrieval", messages: [] }), TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS, ErrorFactory.toolTimeout("retrieve_context", TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS));
1380
1413
  },
1381
1414
  });
1415
+ this.retrieveContextRegistered = true;
1382
1416
  logger.info("[NeuroLink] Memory retrieval tools registered");
1383
1417
  }
1384
1418
  /**
@@ -2730,6 +2764,17 @@ Current user's request: ${currentInput}`;
2730
2764
  async shutdown() {
2731
2765
  try {
2732
2766
  logger.debug("[NeuroLink] Starting graceful shutdown");
2767
+ // Kill host-owned background work first: a shutdown must not leave
2768
+ // child processes or delegate workers running with nobody to collect
2769
+ // them.
2770
+ try {
2771
+ await killAllCommands(this);
2772
+ await cancelBackgroundDelegates(this);
2773
+ logger.debug("[NeuroLink] Background commands and delegates stopped");
2774
+ }
2775
+ catch (error) {
2776
+ logger.warn("[NeuroLink] Background work cleanup failed:", error);
2777
+ }
2733
2778
  try {
2734
2779
  await flushOpenTelemetry();
2735
2780
  await shutdownOpenTelemetry();
@@ -9463,6 +9508,15 @@ Current user's request: ${currentInput}`;
9463
9508
  const mcpServerInfo = createCustomToolServerInfo(name, convertedTool, options?.timeout, options?.maxRetries);
9464
9509
  // Register with toolRegistry using MCPServerInfo directly
9465
9510
  this.toolRegistry.registerServer(mcpServerInfo);
9511
+ // Re-registration replaces options wholesale: omitting `cacheable`
9512
+ // clears a previous `cacheable: false`. A caller replacing a tool whose
9513
+ // results are live state must repeat the flag.
9514
+ if (options?.cacheable === false) {
9515
+ this.uncacheableTools.add(name);
9516
+ }
9517
+ else {
9518
+ this.uncacheableTools.delete(name);
9519
+ }
9466
9520
  // Emit tool registration success event
9467
9521
  this.emitter.emit("tools-register:end", {
9468
9522
  toolName: name,
@@ -9537,6 +9591,7 @@ Current user's request: ${currentInput}`;
9537
9591
  const serverId = `custom-tool-${name}`;
9538
9592
  const removed = this.toolRegistry.unregisterServer(serverId);
9539
9593
  if (removed) {
9594
+ this.uncacheableTools.delete(name);
9540
9595
  logger.info(`Unregistered custom tool: ${name}`);
9541
9596
  }
9542
9597
  return removed;
@@ -10185,6 +10240,7 @@ Current user's request: ${currentInput}`;
10185
10240
  const isCacheEnabled = this.mcpToolResultCache &&
10186
10241
  !options.disableToolCache &&
10187
10242
  !this._disableToolCacheForCurrentRequest &&
10243
+ !this.uncacheableTools.has(toolName) &&
10188
10244
  !toolAnnotations?.destructiveHint;
10189
10245
  const toolResultCache = this.mcpToolResultCache;
10190
10246
  // === MCP ENHANCEMENT: Cache check (before execution) ===
@@ -12576,6 +12632,15 @@ Current user's request: ${currentInput}`;
12576
12632
  }),
12577
12633
  };
12578
12634
  const worker = new NeuroLink(workerConfig);
12635
+ // The cacheable:false flag lives BESIDE the registry, not in it, so a
12636
+ // shared registry alone would re-enable caching on the worker for exactly
12637
+ // the tools whose results are live state (tasks_list, command_status,
12638
+ // collect_results). The worker inherits the host's full uncacheable set.
12639
+ if (options?.shareToolRegistry !== false) {
12640
+ for (const uncacheableName of this.uncacheableTools) {
12641
+ worker.uncacheableTools.add(uncacheableName);
12642
+ }
12643
+ }
12579
12644
  // Constructing an instance rebinds the process-global logger sink to the
12580
12645
  // new instance's emitter — restore the host as the active sink so host
12581
12646
  // log bridges keep flowing while workers come and go.
@@ -12678,6 +12743,371 @@ Current user's request: ${currentInput}`;
12678
12743
  this.hasAgentTools = true;
12679
12744
  return registered;
12680
12745
  }
12746
+ /**
12747
+ * Register the task CHECKLIST toolset — `tasks_create`, `tasks_update`,
12748
+ * `tasks_list` — on this instance (TodoWrite-style planning for a
12749
+ * long-running run). Opt-in and idempotent: existing callers see no new
12750
+ * tools until they ask for them.
12751
+ *
12752
+ * The checklist is session state, not conversation state: it lives outside
12753
+ * the message list, so summarization/compaction cannot lose it, and every
12754
+ * tool result returns the whole list so the model re-anchors for free after
12755
+ * a compaction. Read the same state from host code with
12756
+ * {@link getTaskState} — that is all a completeness gate needs.
12757
+ *
12758
+ * Sessions come from the tool execution context. Call
12759
+ * `setToolContext({ sessionId })` (or run the agent through
12760
+ * `runIsolatedAgent`, which stamps one) so the checklist has a stable
12761
+ * identity; a model tool call with no session anywhere falls back to a
12762
+ * single default checklist per instance rather than one per call. A DIRECT
12763
+ * `executeTool("tasks_create", …)` call should pass
12764
+ * `authContext: { sessionId }` — the tool registry otherwise mints a fresh
12765
+ * id for that one call.
12766
+ *
12767
+ * @see {@link getTaskState} for the host-side read
12768
+ */
12769
+ registerTaskTools() {
12770
+ if (this.hasTaskChecklistTools) {
12771
+ return;
12772
+ }
12773
+ const tools = createChecklistTools(this);
12774
+ for (const [toolName, toolDef] of Object.entries(tools)) {
12775
+ // Never cacheable: every one of these reads or mutates live checklist
12776
+ // state, so a cached `tasks_list` would report a list that has already
12777
+ // moved on — the exact silent staleness the checklist exists to prevent.
12778
+ this.registerTool(toolName, toolDef, { cacheable: false });
12779
+ }
12780
+ this.hasTaskChecklistTools = true;
12781
+ logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} task checklist tools`);
12782
+ }
12783
+ /**
12784
+ * Read a session's task checklist — synchronous, so a completeness gate is
12785
+ * one line of host code:
12786
+ * `getTaskState(id).items.filter(i => i.status === "pending")`.
12787
+ *
12788
+ * Never throws: an unknown session simply has an empty checklist. Omit
12789
+ * `sessionId` to read the session the tools would currently write to (the
12790
+ * instance's tool-context session, or its default checklist).
12791
+ */
12792
+ getTaskState(sessionId) {
12793
+ return getChecklistState(sessionId ?? resolveChecklistSessionId(this));
12794
+ }
12795
+ /**
12796
+ * Drop a session's checklist. Returns whether there was one to drop.
12797
+ * Omit `sessionId` to clear the session the tools currently write to.
12798
+ */
12799
+ clearTaskState(sessionId) {
12800
+ return clearChecklistState(sessionId ?? resolveChecklistSessionId(this));
12801
+ }
12802
+ // ========================================
12803
+ // Async Delegation API (N2)
12804
+ // ========================================
12805
+ /**
12806
+ * Register the background-delegation toolset — `delegate_task` and
12807
+ * `collect_results` — on this instance. Opt-in and idempotent: existing
12808
+ * callers see no new tools until they ask for them.
12809
+ *
12810
+ * Delegation through {@link registerAgentTool} is synchronous — the loop
12811
+ * blocks on each worker. These tools make it asynchronous: `delegate_task`
12812
+ * returns a `workerId` at once and the agent keeps working, then
12813
+ * `collect_results` claims whichever worker finished FIRST. Concurrency is
12814
+ * bounded by the same process-wide pool `registerAgentTool` uses (raised,
12815
+ * never lowered, by `maxConcurrent`), each worker's FULL report is banked to
12816
+ * a file via {@link bankArtifact}, and the outstanding counts ride along in
12817
+ * every `tasks_list` result so the agent learns a worker landed without
12818
+ * polling.
12819
+ *
12820
+ * @param options - Depth ceiling, pool raise, and queue wait
12821
+ * @see {@link spawnDelegate} for the host-side spawn
12822
+ * @see {@link collectDelegates} for the host-side collect
12823
+ */
12824
+ registerDelegationTools(options) {
12825
+ configureDelegation(this, options);
12826
+ if (this.hasBackgroundDelegationTools) {
12827
+ return;
12828
+ }
12829
+ const tools = createDelegationTools(this);
12830
+ for (const [toolName, toolDef] of Object.entries(tools)) {
12831
+ // Never cacheable: `delegate_task` starts a new worker every call and
12832
+ // `collect_results` claims each outcome exactly once. A cached result
12833
+ // would spawn nothing and hand the same worker back twice.
12834
+ this.registerTool(toolName, toolDef, { cacheable: false });
12835
+ }
12836
+ this.hasBackgroundDelegationTools = true;
12837
+ logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} background delegation tools`);
12838
+ }
12839
+ /**
12840
+ * Start a background worker and get its handle immediately — before it has
12841
+ * run anything, and long before it finishes.
12842
+ *
12843
+ * The worker runs through {@link runIsolatedAgent}: a fresh session on a
12844
+ * worker instance sharing THIS instance's tool registry (so live MCP
12845
+ * connections are reused), waste detection, honest stop reasons. Its
12846
+ * complete report is banked when it settles; the outcome you collect carries
12847
+ * a bounded summary plus the read-back call for the rest.
12848
+ *
12849
+ * @example
12850
+ * ```typescript
12851
+ * const a = await neurolink.spawnDelegate({ task: "Audit the auth changes" });
12852
+ * const b = await neurolink.spawnDelegate({ task: "Review the migrations" });
12853
+ * // …keep working…
12854
+ * const first = await neurolink.collectDelegates({ mode: "any" });
12855
+ * ```
12856
+ *
12857
+ * @param options - Task, scope, context, tool allowlist, budgets
12858
+ * @returns The worker id, spawn time, and whether it is queued for a slot
12859
+ * @throws when the task is empty or the caller is at the depth ceiling
12860
+ */
12861
+ async spawnDelegate(options) {
12862
+ return spawnBackgroundDelegate(this, options);
12863
+ }
12864
+ /**
12865
+ * Claim finished background workers — in COMPLETION order, which has nothing
12866
+ * to do with spawn order. Each outcome is handed out exactly once.
12867
+ *
12868
+ * @param request - `{ mode: "any" | "all" }` or `{ workerId }`, plus `waitMs`
12869
+ * @returns Claimed outcomes plus what is still pending/ready
12870
+ */
12871
+ async collectDelegates(request) {
12872
+ return collectBackgroundDelegates(this, request);
12873
+ }
12874
+ /**
12875
+ * Cancel background workers: one by id, or every outstanding worker this
12876
+ * instance spawned. Cancelled workers still settle into a claimable outcome
12877
+ * saying so.
12878
+ *
12879
+ * @param workerId - Cancel just this worker; omit to cancel all
12880
+ * @returns How many workers were cancelled
12881
+ */
12882
+ async cancelDelegates(workerId) {
12883
+ return cancelBackgroundDelegates(this, workerId);
12884
+ }
12885
+ // ========================================
12886
+ // Artifact Banking API (N3)
12887
+ // ========================================
12888
+ /**
12889
+ * This instance's artifact store, created on first use.
12890
+ *
12891
+ * Until now a store existed only when `mcp.outputLimits.strategy` was set to
12892
+ * `"externalize"`, so a caller that just wanted to bank a worker report had
12893
+ * to configure MCP output limits it did not use. This creates one on demand
12894
+ * and registers `retrieve_context` alongside it, so a banked payload is
12895
+ * readable by the model, not only by host code.
12896
+ *
12897
+ * Already-configured instances get the store they already had — the MCP
12898
+ * output normalizer and banking deliberately share one store, so an
12899
+ * externalized tool output and a banked report read back the same way.
12900
+ *
12901
+ * @returns The artifact store backing {@link bankArtifact} / {@link readArtifact}
12902
+ */
12903
+ getArtifactStore() {
12904
+ if (!this.mcpArtifactStore) {
12905
+ this.mcpArtifactStore = new LocalTempArtifactStore();
12906
+ logger.debug("[NeuroLink] Artifact store created on demand (local-temp) for banking");
12907
+ }
12908
+ // A no-op when the constructor already registered it.
12909
+ this.registerMemoryRetrievalTools();
12910
+ return this.mcpArtifactStore;
12911
+ }
12912
+ /**
12913
+ * Bank a payload to a file and get back a pointer to it.
12914
+ *
12915
+ * The payload is stored WHOLE. What you put in the conversation is the
12916
+ * returned `preview` (a bounded head slice) and `readBackHint` (the literal
12917
+ * `retrieve_context` call that fetches the rest) — so a 4 MB worker report
12918
+ * costs a few hundred tokens of context and loses nothing, and compaction
12919
+ * can drop the preview without destroying evidence.
12920
+ *
12921
+ * @example
12922
+ * ```typescript
12923
+ * const ref = await neurolink.bankArtifact(fullReport, {
12924
+ * kind: "worker-report",
12925
+ * label: "delegate:auth-review",
12926
+ * sessionId: "review-1421",
12927
+ * });
12928
+ * // Hand the model ref.preview + ref.readBackHint, never fullReport.
12929
+ * ```
12930
+ *
12931
+ * @param payload Complete text or JSON. Never truncated.
12932
+ * @param options `kind` and `label` are required; see {@link BankArtifactOptions}
12933
+ * @returns Id, bounded preview, byte size, and the read-back call
12934
+ */
12935
+ async bankArtifact(payload, options) {
12936
+ return bankArtifactPayload(this, payload, options);
12937
+ }
12938
+ /**
12939
+ * Read a banked payload back from host code — the programmatic twin of the
12940
+ * model's `retrieve_context({ artifactId })` call.
12941
+ *
12942
+ * Omit `page` for the complete payload; pass `{ offset, limit }` to walk a
12943
+ * large one in windows. Returns null when the id is unknown or expired.
12944
+ *
12945
+ * @param id `artifactId` from a {@link BankedArtifactRef}
12946
+ * @param page Optional character window
12947
+ */
12948
+ async readArtifact(id, page) {
12949
+ return readBankedArtifact(this, id, page);
12950
+ }
12951
+ // ========================================
12952
+ // Background Command API (N4)
12953
+ // ========================================
12954
+ /**
12955
+ * Register the background-command toolset — `run_command_bg`,
12956
+ * `command_status`, `command_output`, `command_kill` — on this instance, and
12957
+ * declare what may be executed. Opt-in and idempotent: existing callers see
12958
+ * no new tools until they ask for them.
12959
+ *
12960
+ * A reviewing agent needs to run real commands — a build, a test suite, a
12961
+ * linter whose output is the evidence for a finding — without blocking its
12962
+ * own loop and without losing a byte of what they printed. These tools start
12963
+ * a command detached, write both streams to files as they arrive, and bank
12964
+ * the COMPLETE files as artifacts when the command settles; the conversation
12965
+ * gets a bounded tail plus the read-back call.
12966
+ *
12967
+ * The policy is not optional. `allowedExecutables` is matched exactly
12968
+ * against `argv[0]`, `cwdRoot` is a realpath-checked sandbox, there is never
12969
+ * a shell, and a command that outlives `defaultTimeoutMs` is killed.
12970
+ *
12971
+ * @param policy - What may run, where, for how long, and how loudly
12972
+ * @see {@link startBackgroundCommand} for the host-side start
12973
+ * @see {@link registerGitTools} for read-only git without a general policy
12974
+ */
12975
+ registerBackgroundCommandTools(policy) {
12976
+ setCommandPolicy(this, policy);
12977
+ if (this.hasBackgroundCommandTools) {
12978
+ return;
12979
+ }
12980
+ const tools = createBackgroundCommandTools(this);
12981
+ for (const [toolName, toolDef] of Object.entries(tools)) {
12982
+ // Never cacheable: every one of these reads or changes live process
12983
+ // state. A cached `command_status` would report a build that has already
12984
+ // finished as still running, for the whole TTL.
12985
+ this.registerTool(toolName, toolDef, { cacheable: false });
12986
+ }
12987
+ this.hasBackgroundCommandTools = true;
12988
+ logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} background command tools`);
12989
+ }
12990
+ /**
12991
+ * Declare (or replace) what this instance may execute, without registering
12992
+ * the model-facing tools. Host code that only drives
12993
+ * {@link startBackgroundCommand} itself needs nothing more than this.
12994
+ *
12995
+ * @param policy - What may run, where, for how long, and how loudly
12996
+ */
12997
+ setBackgroundCommandPolicy(policy) {
12998
+ setCommandPolicy(this, policy);
12999
+ }
13000
+ /**
13001
+ * Start a command in the background and get its task id immediately.
13002
+ *
13003
+ * @example
13004
+ * ```typescript
13005
+ * const { taskId } = await neurolink.startBackgroundCommand(
13006
+ * ["pnpm", "run", "lint"],
13007
+ * { cwd: repoRoot },
13008
+ * );
13009
+ * // …keep working…
13010
+ * const status = await neurolink.awaitBackgroundCommand(taskId);
13011
+ * const full = await neurolink.readArtifact(status.stdout!.artifactId);
13012
+ * ```
13013
+ *
13014
+ * @param argv - Executable first, one entry per argument. Never a command string.
13015
+ * @param options - cwd (sandboxed), timeout, byte cap, env, label, session
13016
+ * @returns The task id, the argv that ran, and when it started
13017
+ * @throws when no policy is set, argv is malformed, the executable is not
13018
+ * allowlisted, the policy vetoes it, or the cwd escapes the sandbox
13019
+ */
13020
+ async startBackgroundCommand(argv, options) {
13021
+ return startCommand(this, argv, options);
13022
+ }
13023
+ /**
13024
+ * Everything known about one command right now — synchronous, so a
13025
+ * mid-loop monitor costs nothing.
13026
+ *
13027
+ * @param taskId - Task id from {@link startBackgroundCommand}
13028
+ * @throws when the task id is unknown to this instance
13029
+ */
13030
+ getBackgroundCommandStatus(taskId) {
13031
+ return getCommandStatus(this, taskId);
13032
+ }
13033
+ /**
13034
+ * Wait for a command to settle. `timeoutMs` bounds the WAIT, not the
13035
+ * command: when it elapses the current status is returned rather than
13036
+ * thrown, so a caller can poll in bounded steps and never lose the job.
13037
+ *
13038
+ * @param taskId - Task id from {@link startBackgroundCommand}
13039
+ * @param opts - `timeoutMs` to bound the wait
13040
+ */
13041
+ async awaitBackgroundCommand(taskId, opts) {
13042
+ return awaitCommand(this, taskId, opts);
13043
+ }
13044
+ /**
13045
+ * Kill a running command — SIGTERM, then SIGKILL five seconds later — and
13046
+ * resolve with its settled status. Whatever it printed first is still
13047
+ * banked: killing a command discards the process, never its output.
13048
+ *
13049
+ * @param taskId - Task id from {@link startBackgroundCommand}
13050
+ * @param signal - Signal to send first. Default SIGTERM
13051
+ */
13052
+ async killBackgroundCommand(taskId, signal) {
13053
+ return killCommand(this, taskId, signal);
13054
+ }
13055
+ /**
13056
+ * Read one character window of a command's output straight from its log
13057
+ * file — while it is still running, or long after it finished. Offsets,
13058
+ * `totalSize` and `hasMore` match `retrieve_context` exactly.
13059
+ *
13060
+ * @param taskId - Task id from {@link startBackgroundCommand}
13061
+ * @param page - Which stream, and which window of it
13062
+ */
13063
+ async readBackgroundCommandOutput(taskId, page) {
13064
+ return readCommandOutput(this, taskId, page);
13065
+ }
13066
+ // ========================================
13067
+ // Read-only Git Toolset (N4.4)
13068
+ // ========================================
13069
+ /**
13070
+ * Register the read-only git toolset — `git_log`, `git_show`, `git_diff`,
13071
+ * `git_blame`, `git_merge_base`, `git_ls_files` — on this instance. Opt-in
13072
+ * and idempotent.
13073
+ *
13074
+ * These are BOUNDED tools, not a shell: the model supplies values (a ref, a
13075
+ * path, a line range), never flags, and each tool assembles a fixed argv
13076
+ * from them. That is what keeps them read-only — a free-form argument string
13077
+ * would carry `--output=<file>` and `diff.external` straight through.
13078
+ *
13079
+ * Registering them widens nothing else: they run under a private
13080
+ * one-executable policy rooted at `repoRoot`, so `run_command_bg` still
13081
+ * cannot execute git, and no general command policy is required.
13082
+ *
13083
+ * @param options - Repository root, plus timeout / byte-cap / preview bounds
13084
+ */
13085
+ registerGitTools(options) {
13086
+ configureGitTools(this, options);
13087
+ if (this.hasGitTools) {
13088
+ return;
13089
+ }
13090
+ const tools = createGitTools(this);
13091
+ for (const [toolName, toolDef] of Object.entries(tools)) {
13092
+ // Never cacheable: the working tree moves under these answers.
13093
+ this.registerTool(toolName, toolDef, { cacheable: false });
13094
+ }
13095
+ this.hasGitTools = true;
13096
+ logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} read-only git tools`);
13097
+ }
13098
+ /**
13099
+ * Run one read-only git command from host code, with the same bounding the
13100
+ * tools get: the complete stdout is banked, the result carries a preview and
13101
+ * the read-back call.
13102
+ *
13103
+ * @param args - Git arguments, e.g. `["log", "--oneline"]`. Assembled by the
13104
+ * caller, which is responsible for every value in them
13105
+ * @param sessionId - Session the command belongs to
13106
+ * @throws when {@link registerGitTools} has not been called
13107
+ */
13108
+ async runGitCommand(args, sessionId) {
13109
+ return runGitArgs(this, args, sessionId);
13110
+ }
12681
13111
  /**
12682
13112
  * Execute an agent network with the given input.
12683
13113
  *
@@ -12775,6 +13205,19 @@ Current user's request: ${currentInput}`;
12775
13205
  this.lastCompactionMessageCount.clear();
12776
13206
  const cleanupErrors = [];
12777
13207
  try {
13208
+ // 0. Kill host-owned background work: a disposed instance must not
13209
+ // leave child processes or delegate workers running.
13210
+ try {
13211
+ await killAllCommands(this);
13212
+ await cancelBackgroundDelegates(this);
13213
+ }
13214
+ catch (error) {
13215
+ const err = error instanceof Error
13216
+ ? error
13217
+ : new Error(`Background work cleanup error: ${String(error)}`);
13218
+ cleanupErrors.push(err);
13219
+ logger.warn("[NeuroLink] Error stopping background work:", error);
13220
+ }
12778
13221
  // 1. Flush and shutdown OpenTelemetry
12779
13222
  try {
12780
13223
  logger.debug("[NeuroLink] Flushing and shutting down OpenTelemetry...");
@@ -21,6 +21,13 @@ export type ArtifactMeta = {
21
21
  contentType: "json" | "text";
22
22
  /** Unix epoch ms when the artifact was created. */
23
23
  createdAt: number;
24
+ /**
25
+ * Human label for a host-banked artifact (e.g. "delegate:auth-review").
26
+ * Absent on artifacts written by the MCP output normalizer.
27
+ */
28
+ label?: string;
29
+ /** What kind of output was banked. Absent for MCP surrogates. */
30
+ kind?: BankedArtifactKind;
24
31
  };
25
32
  /** Lightweight descriptor returned after a successful ArtifactStore.store(). */
26
33
  export type ArtifactRef = {
@@ -33,6 +40,48 @@ export type ArtifactRef = {
33
40
  /** Stored metadata. */
34
41
  meta: ArtifactMeta;
35
42
  };
43
+ /**
44
+ * What a banked payload is, so previews and logs can say so without the
45
+ * caller re-explaining itself. "other" is the escape hatch, not the default.
46
+ */
47
+ export type BankedArtifactKind = "worker-report" | "command-output" | "stage-output" | "other";
48
+ /** How to bank one payload. Only `kind` and `label` are required. */
49
+ export type BankArtifactOptions = {
50
+ /** What this payload is. */
51
+ kind: BankedArtifactKind;
52
+ /** Short human label, e.g. "delegate:auth-review" — shown in logs. */
53
+ label: string;
54
+ /** Session the payload belongs to, recorded on the artifact metadata. */
55
+ sessionId?: string;
56
+ /** Payload shape; decides the on-disk extension. Default "text". */
57
+ contentType?: "json" | "text";
58
+ /** Preview length in characters. Default 1000, hard cap 4000. */
59
+ previewChars?: number;
60
+ };
61
+ /**
62
+ * What the conversation gets instead of the payload: an id, a bounded head
63
+ * slice, and the exact call that reads the rest. The FULL payload is always on
64
+ * disk — a preview is a pointer, never a replacement.
65
+ */
66
+ export type BankedArtifactRef = {
67
+ /** Id to pass to `retrieve_context({ artifactId })`. */
68
+ artifactId: string;
69
+ label: string;
70
+ kind: BankedArtifactKind;
71
+ /** UTF-8 byte size of the complete payload. */
72
+ sizeBytes: number;
73
+ /** Bounded head slice of the payload (characters, not bytes). */
74
+ preview: string;
75
+ /** Literal read-back call, so the model never has to guess the tool. */
76
+ readBackHint: string;
77
+ };
78
+ /** Character window for a paginated artifact read. */
79
+ export type ArtifactPageRequest = {
80
+ /** Character offset to start at. Default 0. */
81
+ offset?: number;
82
+ /** Maximum characters to return. Default: the rest of the payload. */
83
+ limit?: number;
84
+ };
36
85
  /**
37
86
  * Pluggable storage contract for externalized MCP tool outputs.
38
87
  *
@@ -67,4 +116,9 @@ export type ArtifactStore = {
67
116
  */
68
117
  export type IndexEntry = ArtifactMeta & {
69
118
  path: string;
119
+ /**
120
+ * Loaded from disk rather than stored by this process. Another process's
121
+ * work: readable, but never expired by this process's `cleanup()`.
122
+ */
123
+ rehydrated?: boolean;
70
124
  };