openclaw-lark-multi-agent 1.8.4 → 1.8.6

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.
@@ -5,6 +5,8 @@ export type SessionRuntimeStatus = {
5
5
  status: string;
6
6
  running: boolean;
7
7
  checkedAt: number;
8
+ /** Positive public activity evidence, never inferred from !running. */
9
+ idleConfirmed?: boolean;
8
10
  contextUsedTokens?: number;
9
11
  contextLimitTokens?: number;
10
12
  };
@@ -14,6 +14,7 @@ export function normalizeSessionRuntimeStatus(value) {
14
14
  const contextUsedTokens = session?.totalTokensFresh !== false && typeof used === "number" && Number.isFinite(used) && used >= 0 ? used : undefined;
15
15
  const contextLimitTokens = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : undefined;
16
16
  return { status: running ? "running" : status, running, checkedAt: Date.now(),
17
+ ...(session?.hasActiveRun === false && !running ? { idleConfirmed: true } : {}),
17
18
  ...(contextUsedTokens !== undefined ? { contextUsedTokens } : {}),
18
19
  ...(contextLimitTokens !== undefined ? { contextLimitTokens } : {}),
19
20
  };
@@ -0,0 +1,65 @@
1
+ # 1.8.5 — reliability candidate (not published)
2
+
3
+ - Long silent tools/API calls have no LMA 24-hour failure horizon. Ten-minute
4
+ silence changes presentation only; exact-run custody survives disconnect and
5
+ process restart in LMA-owned SQLite. Queries have short independent deadlines.
6
+ - Superseded observers transfer exact-run delivery custody synchronously. Failed
7
+ replacement admission leaves a healthy old collector alive. Admission, owner,
8
+ actual batch and persistent stop generations fence late work and UI updates.
9
+ - Inbound requests start with Typing. Get requires actual accepted custody, never
10
+ local queueing, send initiation, a boolean eligibility value or `in_flight`.
11
+ DONE belongs to the exact settled task. UI API failure is independently retried
12
+ and cannot block admission or result delivery.
13
+ - Body, file/image/document and supplemental outputs share durable platform
14
+ operation receipts and atomic context/checkpoint registration. Unknown platform
15
+ outcomes fence retry/fallback. Exact platform IDs deduplicate echoes; independent
16
+ identical-text tasks remain distinct. Confirmed partial chunks are recoverable.
17
+ - Historical context preview/explicit registration (never resend):
18
+ `node scripts/audit-delivery-context.mjs --db <explicit-path> --bot <name>`.
19
+ `--register --outbox <id> --chat <id> --verified-message-id <actual-id>` requires
20
+ independently verified receipts. Upgrade does **not** auto-backfill live history.
21
+
22
+ ## Persistent real-time insertion (coordinated bridge + lma-core 0.2.0)
23
+
24
+ - Busy-session inputs reserve durable message custody before ingress awaits.
25
+ Short submit/query RPCs and one paced bridge recovery scheduler retain the
26
+ original input beyond 3/8 seconds and hours of not-ready observations. There
27
+ is no insertion age deadline or automatic ordinary-send timeout fallback.
28
+ - The production plugin now actually invokes the **public**
29
+ `queueAgentHarnessMessage(..., { onQueueAccepted })`. Only the backend's
30
+ `onQueueAccepted(true)` is persisted as acceptance and drives Get. Immediate
31
+ SDK `true` means an attempt, not a receipt. SDK `false` or explicit callback
32
+ `false` is a no-effect refusal and permits paced retry of the original target.
33
+ - Stable input ID + payload/session-generation/run/stop identity and fsynced
34
+ plugin attempt journal make late callbacks queryable after RPC timeout or
35
+ disconnect. Restart preserves accepted/unknown. Missing callbacks, missing
36
+ journals after a possible attempt and crash ambiguity stay **unknown**, never
37
+ ordinary-replayed; `queueIdentity` is NOT assumed to deduplicate host effects.
38
+ - Canonical bot/session generation, archive/reset and persistent stop epochs
39
+ fence admission and reactions. Lifecycle + public exact-terminal observations
40
+ prevent a known ended original run from being SDK-retargeted to its successor.
41
+ Only an input proven never dispatched to the backend, with confirmed original
42
+ terminal and session idle, is explicitly handed back to the ordinary queue
43
+ once, with a status notice. Successor runs in the same session are legitimate
44
+ session work, not a different chat; they are not silently selected by retries.
45
+
46
+ ### Honest public-runtime boundary (not a feature-disable gate)
47
+
48
+ `atomicRunBound` remains **false**: public SDK resolution plus queue invocation
49
+ is synchronous locally, but the host offers no portable final-effect original-
50
+ run CAS across awaited backend preparation. We do **not** claim that stronger
51
+ host guarantee or model consumption. It is not a prerequisite for the user's
52
+ current-session input semantics, so builtin insertion is enabled with truthful
53
+ callback receipts instead of being universally `capability_blocked`.
54
+
55
+ Both inspected builtin versions implement the acceptance callback. A legacy or
56
+ alternative backend that never calls it remains **acceptance unknown** with no
57
+ Get/no automatic resend. Real public-SDK typed synthetic-harness and application
58
+ integration tests execute queue methods; these are offline transport conformance,
59
+ not live model/Feishu acceptance or proof that every optional backend supports
60
+ callbacks. No private SDK imports, synthetic authority, host DB edits or system
61
+ installation changes are involved.
62
+
63
+ This coordinated protocol candidate requires the matching bridge and lma-core
64
+ plugin. This file is release preparation, **not permission to publish, deploy or
65
+ restart anything**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -13,7 +13,8 @@
13
13
  "prepack": "npm run build",
14
14
  "prepublishOnly": "npm test && npm pack --dry-run",
15
15
  "build:go-plugin": "tsc -p plugins/lma-go/tsconfig.json",
16
- "build:core-plugin": "tsc -p plugins/lma-core/tsconfig.json"
16
+ "build:core-plugin": "tsc -p plugins/lma-core/tsconfig.json",
17
+ "test:sdk-contract": "tsc --noEmit --strict --target ES2022 --module ESNext --moduleResolution bundler --skipLibCheck tests/reliability-public-sdk.test.ts"
17
18
  },
18
19
  "dependencies": {
19
20
  "@larksuiteoapi/node-sdk": "^1.73.3",
@@ -67,7 +68,12 @@
67
68
  "README.md",
68
69
  "README.zh-CN.md",
69
70
  "LICENSE",
70
- "SECURITY.md"
71
+ "SECURITY.md",
72
+ "plugins/lma-core/dist",
73
+ "plugins/lma-core/openclaw.plugin.json",
74
+ "plugins/lma-core/package.json",
75
+ "plugins/lma-core/README.md",
76
+ "docs/releases/1.8.5.md"
71
77
  ],
72
78
  "engines": {
73
79
  "node": ">=22"
@@ -0,0 +1,83 @@
1
+ # lma-core
2
+
3
+ The single consolidated LMA gateway plugin. Replaces:
4
+
5
+ - `lma-steer` 0.1.1 → steer module (`lma.steer`)
6
+ - `lma-go` 0.1.0 → go module (`lma.go.bind/capabilities/snapshot`, `before_tool_call` guard)
7
+
8
+ plus the new compaction R&D module (`lma.core.capabilities`).
9
+
10
+ ## Why one plugin
11
+
12
+ Each extra plugin is one more manifest + allowlist + upgrade path that can
13
+ silently fail to load (two production incidents in one week proved it).
14
+ lma-core keeps ALL gateway method names and the persisted go-bindings path
15
+ (`<stateDir>/lma-go/bindings.json`) unchanged, so the LMA bridge and existing
16
+ state migrate with zero changes.
17
+
18
+ ## Gateway methods
19
+
20
+ | Method | Scope | Purpose |
21
+ |---|---|---|
22
+ | `lma.steer` | (bridge) | Durable session-input submit/query; actual backend callback acceptance |
23
+ | `lma.steer.stop` | (bridge) | Persist session stop-epoch cutoff; late old attempts cannot revive |
24
+ | `lma.steer.capabilities` | (bridge) | Truthful acceptance/atomic-target capability report |
25
+ | `lma.go.bind` | operator.admin | Persist a checker tool-isolation binding |
26
+ | `lma.go.capabilities` | operator.read | Report Go module capability surface |
27
+ | `lma.go.snapshot` | operator.read | Session-scoped task snapshot |
28
+ | `lma.core.capabilities` | operator.read | Module inventory + compaction strategy report |
29
+
30
+ ## Compaction R&D (Phase 1)
31
+
32
+ Custom compaction (tool-trim: drop tool payload, keep text) is being built as
33
+ a gateway-sanctioned projection — the transcript stays intact; only the model
34
+ context gets filtered. Phase 1 ships the capability report and an
35
+ env-gated api-surface probe (`LMA_CORE_LOG_API=1` logs the plugin api's
36
+ enumerable surface at registration) so Phase 2 targets real hook names
37
+ (candidates: `llmInputHook`, `Before/AfterCompactionHook`, ContextEngine
38
+ projections).
39
+
40
+ ## Build
41
+
42
+ ```bash
43
+ cd plugins/lma-core && ../../node_modules/.bin/tsc -p tsconfig.json
44
+ ```
45
+
46
+ (Repo root `npm run build:core-plugin` runs the same.)
47
+
48
+ ## 0.2.0 persistent callback-confirmed insertion
49
+
50
+ `lma.steer.capabilities` returns protocol 2, `acceptanceCallback:true`,
51
+ `durableAttempts:true`, `sessionGenerationBound:true`, **`atomicRunBound:false`**.
52
+ The latter is a disclosed public-host limitation, not a gate disabling insertion.
53
+
54
+ `lma.steer` requires `sessionKey`, `text`, stable `insertionId`, `expectedRunId`,
55
+ `expectedSessionId`, and integer `stopEpoch`. The bridge pins these before its
56
+ attempt journal and drives paced recovery; the method returns promptly rather
57
+ than holding an RPC for readiness/acceptance. `queryOnly:true` never queues.
58
+ The plugin synchronously resolves/checks/dispatches using public SDK exports,
59
+ fsyncs an unknown-attempt fence before queue invocation, and records the actual
60
+ `onQueueAccepted(true/false)` asynchronously under that exact attempt identity.
61
+ No `deliveryTimeoutMs` or unverified cross-version abort behavior is assumed.
62
+
63
+ - SDK immediate false: no queue dispatch, pending/no-effect, safe paced retry.
64
+ - SDK immediate true: attempt/unknown; only backend callback true permits Get.
65
+ - Explicit callback false: no-effect refusal; retries remain original-target
66
+ scoped and paced, never a switch to whatever run happens to be current.
67
+ - No callback, lost journal after attempt, crash at/before dispatch: unknown,
68
+ never a retry or ordinary fallback. Stable queue ID is not host idempotency.
69
+ - Delayed callback true: durable accepted, readable on later query/restart.
70
+ - Stop/reset epoch or changed generation/archive: cancel/pause, no migration.
71
+ - Exact old-run terminal + proven never dispatched + positive session idle: the bridge
72
+ explicitly releases the original pending input to ordinary custody once.
73
+
74
+ The journal lives under `<OPENCLAW_STATE_DIR>/lma-core/steer-custody` (default
75
+ `~/.openclaw`). Only explicit never-attempted `capability_blocked` records from
76
+ an earlier local candidate are migrated; attempted uncertainty is not erased.
77
+ No OpenClaw internal DB, private hashed imports, handle replacement or invented
78
+ source authority is used. Backend acceptance is not model consumption or DONE.
79
+
80
+ Both inspected builtin SDK versions call the acceptance callback. Backends that
81
+ omit it stay explicitly unconfirmed and cannot get Get. The public SDK does not
82
+ provide final-effect original-run CAS across backend awaits: session generation
83
+ and observed lifecycle fencing must not be advertised as that stronger promise.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * lma-core :: compaction module (Phase 1: capability surface)
3
+ * -----------------------------------------------------------
4
+ * Home of the custom compaction R&D (user directive 2026-10-10: tool-trim —
5
+ * drop tool payload, keep text — via the gateway's sanctioned internals
6
+ * instead of external storage writes).
7
+ *
8
+ * Phase 1 ships:
9
+ * - `lma.core.capabilities`: module inventory + compaction strategy report.
10
+ * - Registration-time api-surface probe (env LMA_CORE_LOG_API=1): logs the
11
+ * plugin api's enumerable surface so Phase 2 can target the real
12
+ * llm-input / compaction hook names instead of guessing.
13
+ *
14
+ * Phase 2 (next): tool-trim projection using the discovered hooks
15
+ * (candidates from the SDK: runAgentHarnessLlmInputHook,
16
+ * Before/AfterCompactionHook, ContextEngine projections).
17
+ */
18
+ export function registerCompaction(api) {
19
+ if (process.env.LMA_CORE_LOG_API === "1") {
20
+ try {
21
+ const surface = Object.getOwnPropertyNames(Object.getPrototypeOf(api) ?? {})
22
+ .concat(Object.keys(api ?? {}));
23
+ const unique = Array.from(new Set(surface)).sort();
24
+ console.log(`[lma-core] compaction probe: plugin api surface (${unique.length}): ${unique.join(", ")}`);
25
+ }
26
+ catch (err) {
27
+ console.warn(`[lma-core] compaction probe failed:`, err.message);
28
+ }
29
+ }
30
+ api.registerGatewayMethod("lma.core.probe", ({ respond }) => {
31
+ // Phase-2 R&D: dump the live plugin api + runtime surface (names only,
32
+ // no values — nothing sensitive) so toolTrim targets real methods.
33
+ const surface = (obj, depth = 0) => {
34
+ if (!obj || typeof obj !== "object" || depth > 2)
35
+ return typeof obj;
36
+ const out = {};
37
+ let keys = [];
38
+ try {
39
+ keys = Object.keys(obj);
40
+ }
41
+ catch {
42
+ return "inaccessible";
43
+ }
44
+ try {
45
+ const proto = Object.getPrototypeOf(obj);
46
+ if (proto && proto !== Object.prototype) {
47
+ keys = Array.from(new Set([...Object.getOwnPropertyNames(proto), ...keys]));
48
+ }
49
+ }
50
+ catch { /* ignore */ }
51
+ for (const k of keys.slice(0, 120)) {
52
+ let v;
53
+ try {
54
+ v = obj[k];
55
+ }
56
+ catch {
57
+ out[k] = "getter-threw";
58
+ continue;
59
+ }
60
+ out[k] = typeof v === "function" ? "fn" : surface(v, depth + 1);
61
+ }
62
+ return out;
63
+ };
64
+ respond(true, { api: surface(api), version: "0.1.0" });
65
+ }, { scope: "operator.read" });
66
+ api.registerGatewayMethod("lma.core.capabilities", ({ respond }) => {
67
+ respond(true, {
68
+ plugin: "lma-core",
69
+ modules: ["steer", "go", "compaction"],
70
+ methods: ["lma.steer", "lma.go.bind", "lma.go.capabilities", "lma.go.snapshot", "lma.core.capabilities"],
71
+ compaction: {
72
+ rpcStrategies: ["semantic (LLM summarize)", "maxLines (transcript tail truncate)"],
73
+ toolTrim: {
74
+ status: "phase-1-probe",
75
+ target: "projection-style: keep transcript intact, filter tool payload from model context",
76
+ candidateHooks: ["llmInputHook", "beforeCompactionHook", "contextEngineProjection"],
77
+ },
78
+ },
79
+ version: "0.1.0",
80
+ });
81
+ }, { scope: "operator.read" });
82
+ }
@@ -0,0 +1,25 @@
1
+ export function checkTool(bindings, event, ctx) {
2
+ for (const b of bindings) {
3
+ // Deny also on changed/missing generation: an old checker must not become unrestricted.
4
+ if (ctx.sessionKey === b.checkerSessionKey || ctx.sessionId === b.checkerSessionId)
5
+ return { block: true, blockReason: "LMA Go message-review Checker cannot invoke tools" };
6
+ }
7
+ }
8
+ export function parseBindings(value) {
9
+ if (!Array.isArray(value))
10
+ throw new Error("bindings must be an array");
11
+ const seen = new Set();
12
+ return value.map((item) => {
13
+ if (!item || typeof item !== "object")
14
+ throw new Error("invalid binding");
15
+ for (const k of ["checkerSessionKey", "checkerSessionId", "executorSessionKey", "executorSessionId"])
16
+ if (typeof item[k] !== "string" || !item[k].trim())
17
+ throw new Error(`missing ${k}`);
18
+ if (!item.checkerSessionKey.startsWith("agent:") || !item.executorSessionKey.startsWith("agent:"))
19
+ throw new Error("canonical session keys required");
20
+ if (item.checkerSessionKey === item.executorSessionKey || item.checkerSessionId === item.executorSessionId || seen.has(item.checkerSessionKey))
21
+ throw new Error("ambiguous binding");
22
+ seen.add(item.checkerSessionKey);
23
+ return { ...item };
24
+ });
25
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { parseBindings } from "./go-policy.js";
4
+ /** Atomic persisted restriction registry. Never auto-unbind on restart/stop. */
5
+ export class GoPolicyRegistry {
6
+ path;
7
+ bindings;
8
+ constructor(path, initial = []) {
9
+ this.path = path;
10
+ let value;
11
+ try {
12
+ value = JSON.parse(readFileSync(path, "utf8"));
13
+ }
14
+ catch (e) {
15
+ if (e.code !== "ENOENT")
16
+ throw e;
17
+ value = initial;
18
+ }
19
+ this.bindings = parseBindings(value);
20
+ }
21
+ list() { return this.bindings.map(b => ({ ...b })); }
22
+ bind(value) {
23
+ const [binding] = parseBindings([value]);
24
+ const previous = this.bindings.find(b => b.checkerSessionKey === binding.checkerSessionKey);
25
+ if (previous) {
26
+ if (JSON.stringify(previous) !== JSON.stringify(binding))
27
+ throw new Error("Binding identity conflict");
28
+ return;
29
+ }
30
+ const next = parseBindings([...this.bindings, binding]);
31
+ mkdirSync(dirname(this.path), { recursive: true });
32
+ const temp = `${this.path}.tmp`;
33
+ writeFileSync(temp, JSON.stringify(next), { mode: 0o600 });
34
+ renameSync(temp, this.path);
35
+ this.bindings = next;
36
+ }
37
+ }
@@ -0,0 +1,42 @@
1
+ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
2
+ import { join } from "node:path";
3
+ import { checkTool } from "./go-policy.js";
4
+ import { GoPolicyRegistry } from "./go-registry.js";
5
+ /**
6
+ * lma-core :: go module
7
+ * ---------------------
8
+ * Exact-identity persisted Go tool enforcement for the independent LMA Go
9
+ * Checker (message-review checkers may not invoke tools).
10
+ *
11
+ * Gateway methods (names kept identical to the former standalone lma-go
12
+ * plugin): `lma.go.bind`, `lma.go.capabilities`, `lma.go.snapshot`.
13
+ * The persisted registry path is also unchanged
14
+ * (`<stateDir>/lma-go/bindings.json`) so existing bindings carry over.
15
+ */
16
+ export function registerGo(api) {
17
+ const registry = new GoPolicyRegistry(join(resolveStateDir(), "lma-go", "bindings.json"), api.pluginConfig?.bindings ?? []);
18
+ api.on("before_tool_call", (event, ctx) => checkTool(registry.list(), event, ctx), { priority: 10000 });
19
+ api.registerGatewayMethod("lma.go.bind", ({ params, respond }) => {
20
+ try {
21
+ registry.bind(params.binding);
22
+ respond(true, { persisted: true, binding: params.binding });
23
+ }
24
+ catch (e) {
25
+ respond(false, undefined, { code: "INVALID_REQUEST", message: e.message });
26
+ }
27
+ }, { scope: "operator.admin" });
28
+ api.registerGatewayMethod("lma.go.capabilities", ({ respond }) => {
29
+ respond(true, { mode: "independent", checkerToolsIsolated: true, taskQuery: true,
30
+ atomicSnapshot: false, nativeGoalAuthority: false });
31
+ }, { scope: "operator.read" });
32
+ api.registerGatewayMethod("lma.go.snapshot", ({ params, respond }) => {
33
+ if (typeof params.sessionKey !== "string" || typeof params.sessionId !== "string") {
34
+ respond(false, undefined, { code: "INVALID_REQUEST", message: "exact session identity required" });
35
+ return;
36
+ }
37
+ const tasks = api.runtime.tasks.runs.bindSession({ sessionKey: params.sessionKey }).list();
38
+ respond(true, { sessionKey: params.sessionKey, requestedSessionId: params.sessionId, tasks,
39
+ queryAvailable: true, atomicSnapshot: false,
40
+ boundaries: ["tasks scoped to canonical key; adapter pins generation and run ids", "LMA checks its own queue and delivery ledger"] });
41
+ }, { scope: "operator.read" });
42
+ }
@@ -0,0 +1,29 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { registerSteer } from "./steer.js";
3
+ import { registerGo } from "./go.js";
4
+ import { registerCompaction } from "./compaction.js";
5
+ import { registerToolTrim } from "./tooltrim.js";
6
+ /**
7
+ * lma-core — the single consolidated LMA gateway plugin
8
+ * ------------------------------------------------------
9
+ * Replaces the former standalone plugins:
10
+ * - lma-steer (0.1.1) -> steer module (`lma.steer`)
11
+ * - lma-go (0.1.0) -> go module (`lma.go.*`, before_tool_call guard)
12
+ * plus the new compaction R&D module (`lma.core.capabilities`).
13
+ *
14
+ * One manifest, one allowlist entry, one upgrade path. All gateway method
15
+ * names and the persisted go-bindings path are unchanged, so the LMA bridge
16
+ * and existing state migrate with zero changes.
17
+ */
18
+ export default definePluginEntry({
19
+ id: "lma-core",
20
+ name: "LMA Core",
21
+ description: "Consolidated LMA plugin: mid-run steer injection, Go checker tool enforcement, custom compaction R&D.",
22
+ register(api) {
23
+ registerSteer(api);
24
+ registerGo(api);
25
+ registerCompaction(api);
26
+ registerToolTrim(api);
27
+ console.log("[lma-core] registered: steer (lma.steer), go (lma.go.*), compaction (lma.core.*), tooltrim (lma.core.compact.tooltrim)");
28
+ },
29
+ });
@@ -0,0 +1,212 @@
1
+ import { resolveActiveEmbeddedRunSessionId, queueAgentHarnessMessage, onAgentEvent } from "openclaw/plugin-sdk/agent-harness-runtime";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
4
+ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
+ import { join } from "node:path";
6
+ const canonical = (key) => /^agent:/.test(key) ? key : `agent:main:${key}`;
7
+ const nonempty = (value) => typeof value === 'string' && !!value.trim();
8
+ /** Short submit/query RPCs. The bridge owns one paced recovery scheduler, so
9
+ * callback lifetime is independent of the RPC deadline. Resolve + queue are
10
+ * synchronous on one event-loop turn. This is session-generation custody and
11
+ * observed lifecycle fencing, NOT a host final-effect original-run CAS. */
12
+ export function registerSteer(api) {
13
+ const dir = join(resolveStateDir(), 'lma-core', 'steer-custody');
14
+ const active = new Map(), ended = new Set();
15
+ const unsubscribe = onAgentEvent(event => {
16
+ if (!event.sessionKey || !event.runId || event.stream !== 'lifecycle')
17
+ return;
18
+ const key = canonical(event.sessionKey), phase = String(event.data?.phase);
19
+ if (['end', 'error'].includes(phase)) {
20
+ ended.add(`${key}\0${event.runId}`);
21
+ if (active.get(key)?.runId === event.runId)
22
+ active.delete(key);
23
+ if (ended.size > 2048)
24
+ ended.delete(ended.values().next().value);
25
+ }
26
+ else if (phase === 'start') {
27
+ const startedAt = Number(event.data?.startedAt) || event.ts;
28
+ if ((active.get(key)?.startedAt ?? 0) > startedAt)
29
+ return;
30
+ active.set(key, { runId: event.runId, sessionId: event.sessionId, startedAt });
31
+ if (active.size > 512)
32
+ active.delete(active.keys().next().value);
33
+ }
34
+ });
35
+ const pathFor = (id) => join(dir, createHash('sha256').update(id).digest('hex') + '.json');
36
+ const readJson = (path) => { try {
37
+ return JSON.parse(readFileSync(path, 'utf8'));
38
+ }
39
+ catch (e) {
40
+ if (e.code === 'ENOENT')
41
+ return;
42
+ throw e;
43
+ } };
44
+ // Durable no-replay fence, including power/process loss around rename.
45
+ const writeJson = (path, value) => {
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ const temp = path + '.tmp';
48
+ writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
49
+ const fd = openSync(temp, 'r+');
50
+ try {
51
+ fsyncSync(fd);
52
+ }
53
+ finally {
54
+ closeSync(fd);
55
+ }
56
+ renameSync(temp, path);
57
+ // Windows permits opening some directories but fsync then fails EPERM.
58
+ // The file is already durable+renamed: never report a false failed attempt.
59
+ if (process.platform !== 'win32') {
60
+ const parent = openSync(dir, 'r');
61
+ try {
62
+ fsyncSync(parent);
63
+ }
64
+ finally {
65
+ closeSync(parent);
66
+ }
67
+ }
68
+ };
69
+ const read = (id) => readJson(pathFor(id));
70
+ const save = (record) => { record.ordinaryEligible = record.attempted === false && record.everAttempted !== true; writeJson(pathFor(record.insertionId), record); };
71
+ const stopPath = (key) => pathFor(`session-stop:${key}`);
72
+ const cutoff = (key) => readJson(stopPath(key))?.epoch ?? 0;
73
+ api.registerGatewayMethod('lma.steer.capabilities', ({ respond }) => respond(true, { protocol: 2, atomicRunBound: false,
74
+ acceptanceCallback: true, durableAttempts: true, sessionGenerationBound: true,
75
+ reason: 'Backend callback confirms acceptance, not consumption. Original-run final-effect CAS is unavailable.' }));
76
+ api.registerGatewayMethod('lma.steer.stop', ({ params, respond }) => {
77
+ try {
78
+ if (!nonempty(params.sessionKey) || !Number.isSafeInteger(params.stopEpoch))
79
+ throw new Error('sessionKey and stopEpoch required');
80
+ const key = canonical(params.sessionKey), epoch = Math.max(cutoff(key), params.stopEpoch);
81
+ writeJson(stopPath(key), { epoch });
82
+ respond(true, { sessionKey: key, stopEpoch: epoch });
83
+ }
84
+ catch (error) {
85
+ respond(false, undefined, { code: 'INTERNAL', message: String(error) });
86
+ }
87
+ });
88
+ api.registerGatewayMethod('lma.steer', ({ params, respond }) => {
89
+ if (!nonempty(params.sessionKey) || !nonempty(params.text) || !nonempty(params.insertionId) || !nonempty(params.expectedRunId) ||
90
+ !nonempty(params.expectedSessionId) || !Number.isSafeInteger(params.stopEpoch)) {
91
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'Immutable input, session/run identity and stopEpoch required' });
92
+ return;
93
+ }
94
+ const key = canonical(params.sessionKey);
95
+ try {
96
+ let old = read(params.insertionId);
97
+ const textHash = createHash('sha256').update(params.text).digest('hex');
98
+ // Upgrade the prior unpublished no-effect candidate's journal only.
99
+ // Never reinterpret an attempted/unknown row as safe to send.
100
+ if (old && old.status === 'capability_blocked' && old.attempted === false &&
101
+ old.textHash === textHash && old.sessionKey === key && old.expectedRunId === params.expectedRunId &&
102
+ (!old.sessionId || old.sessionId === params.expectedSessionId)) {
103
+ old = { ...old, sessionId: params.expectedSessionId, stopEpoch: params.stopEpoch, status: 'pending', attempt: 0 };
104
+ save(old);
105
+ }
106
+ if (old && (old.textHash !== textHash || old.sessionKey !== key || old.expectedRunId !== params.expectedRunId ||
107
+ old.sessionId !== params.expectedSessionId || old.stopEpoch !== params.stopEpoch))
108
+ throw new Error('Immutable insertion identity/payload mismatch');
109
+ if (params.queryOnly === true && !old) {
110
+ respond(true, { insertionId: params.insertionId, expectedRunId: params.expectedRunId, sessionId: params.expectedSessionId,
111
+ status: 'unknown', attempted: true, reason: 'journal unavailable; not replayed' });
112
+ return;
113
+ }
114
+ const record = old || { insertionId: params.insertionId, sessionKey: key, expectedRunId: params.expectedRunId, textHash,
115
+ sessionId: params.expectedSessionId, stopEpoch: params.stopEpoch, status: 'pending', attempted: false, attempt: 0 };
116
+ if (params.cancel === true || record.stopEpoch < cutoff(key)) {
117
+ record.status = 'stopped';
118
+ save(record);
119
+ respond(true, record);
120
+ return;
121
+ }
122
+ if (record.attempted || record.status !== 'pending' || params.queryOnly === true) {
123
+ respond(true, record);
124
+ return;
125
+ }
126
+ if (params.originalRunEnded === true || ended.has(`${key}\0${record.expectedRunId}`)) {
127
+ record.status = 'target_ended';
128
+ record.reason = 'original run ended without an insertion attempt';
129
+ save(record);
130
+ respond(true, record);
131
+ return;
132
+ }
133
+ const observedRun = active.get(key);
134
+ if (observedRun && observedRun.runId !== record.expectedRunId && (!observedRun.sessionId || observedRun.sessionId === record.sessionId)) {
135
+ // A same-session successor is not a generation/authorization change.
136
+ // Wait for the original exact terminal; never SDK-retarget this retry.
137
+ record.reason = 'target_not_ready';
138
+ save(record);
139
+ respond(true, record);
140
+ return;
141
+ }
142
+ const sessionId = resolveActiveEmbeddedRunSessionId(key);
143
+ if (!sessionId) {
144
+ record.reason = 'target_not_ready';
145
+ save(record);
146
+ respond(true, record);
147
+ return;
148
+ }
149
+ if (record.sessionId !== sessionId) {
150
+ record.status = 'target_changed';
151
+ record.reason = 'session generation changed; no automatic migration';
152
+ save(record);
153
+ respond(true, record);
154
+ return;
155
+ }
156
+ // Crash even just BEFORE queue invocation remains unknown. queueIdentity
157
+ // is an item identity, NEVER a host dedupe/retry token.
158
+ const previouslyAttempted = record.everAttempted === true;
159
+ record.everAttempted = true;
160
+ record.attempted = true;
161
+ record.status = 'unknown';
162
+ record.attempt++;
163
+ record.reason = 'awaiting_backend_acceptance';
164
+ save(record);
165
+ const attempt = record.attempt;
166
+ const queued = queueAgentHarnessMessage(sessionId, params.text, { steeringMode: 'all', queueIdentity: `${record.insertionId}:${attempt}`,
167
+ onQueueAccepted: (accepted) => {
168
+ try {
169
+ const current = read(record.insertionId);
170
+ if (!current || current.attempt !== attempt || current.acceptedAt)
171
+ return;
172
+ if (current.status === 'stopped' || current.stopEpoch < cutoff(key)) {
173
+ current.status = 'stopped';
174
+ if (accepted)
175
+ current.acceptanceAfterStop = true;
176
+ }
177
+ else if (accepted) {
178
+ current.status = 'accepted';
179
+ current.acceptedAt = Date.now();
180
+ current.reason = 'backend_onQueueAccepted_true';
181
+ }
182
+ else {
183
+ current.status = 'pending';
184
+ current.attempted = false;
185
+ current.reason = 'backend_rejected_no_effect';
186
+ }
187
+ save(current);
188
+ }
189
+ catch (error) {
190
+ // No durable receipt -> no Get/retry. Never throw into a backend
191
+ // after it accepted input, including an asynchronous callback.
192
+ api.logger?.warn?.(`lma.steer callback journal failure: ${String(error)}`);
193
+ }
194
+ } });
195
+ if (!queued) {
196
+ const current = read(record.insertionId);
197
+ if (current.attempt === attempt && !current.acceptedAt) {
198
+ current.everAttempted = previouslyAttempted;
199
+ current.attempted = false;
200
+ current.status = 'pending';
201
+ current.reason = 'target_not_ready';
202
+ save(current);
203
+ }
204
+ }
205
+ respond(true, read(record.insertionId));
206
+ }
207
+ catch (error) {
208
+ respond(false, undefined, { code: 'INTERNAL', message: `lma.steer: ${error instanceof Error ? error.message : String(error)}` });
209
+ }
210
+ });
211
+ return unsubscribe;
212
+ }