@alignfirst/service-openclaw-plugin 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,8 +43,7 @@ their IDs to the corresponding native contract:
43
43
  The plugin observes successful native `message` actions but never creates a thread itself.
44
44
 
45
45
  - Slack evidence is a `send` to the current parent channel with an explicit `threadId`, nonempty body, `deliveryStatus: "sent"`, and `messageDelivery: { status: "settled", partialDelivery: false }`. The result must include a channel-kind `target` naming the parent and a nonempty `messageId`; `result.receipt.threadId`, when present, must match the requested thread. The plugin-path `{ ok, result }` shape used by team-qualified Slack sends is rejected.
46
- - Discord evidence is a successful anchored `thread-create` in the current parent channel with a
47
- nonempty starter and returned thread ID. A partial result is rejected.
46
+ - Discord evidence is the starter `thread-reply` into a thread the session created. A successful `thread-create` in the current parent channel, anchored on a message and returning a thread ID, records the thread for the session; it is never evidence by itself, even with content. The `thread-reply` into that thread must carry a nonempty starter, and a returned `result.channelId` must name the thread; `result.messageId` becomes the starter message ID. A partial result is rejected at either step. The created thread is kept in gateway memory for one hour.
48
47
  - `thread_handoff { "action": "start", "threadId": "..." }` returns `queued` or
49
48
  `alreadyStarted`, plus the opaque handoff ID and canonical target session key.
50
49
 
@@ -61,7 +60,7 @@ The receiving turn calls `thread_handoff { "action": "claim" }` once before task
61
60
  Inputs are strict. Errors begin with a stable reason code: `unsupportedContext`,
62
61
  `unverifiedThreadDelivery`, `conflictingHandoff`, `invalidTarget`, or
63
62
  `unavailablePersistentState`. A capacity failure preserves `STORE_LIMIT_EXCEEDED` as its cause.
64
- Rejected eligible delivery observations emit one debug line with `notSent`, `partialDelivery`, `channelMismatch`, `threadMismatch`, `missingMessageId`, `missingThread`, `missingStarter`, or `accountMismatch`. The line contains no starter text or result payload.
63
+ Rejected eligible delivery observations emit one debug line with `notSent`, `partialDelivery`, `channelMismatch`, `threadMismatch`, `missingMessageId`, `missingThread`, `missingStarter`, `unknownThread`, or `accountMismatch`. The line contains no starter text or result payload.
65
64
 
66
65
  Starts are limited to distinct regular parent-channel sessions. DMs, group DMs, Slack Agent View,
67
66
  ACP, subagent, cron, global/shared, already-threaded, and ambiguous cross-account routes are not
@@ -73,7 +72,7 @@ The plugin commits a pending record before dispatching `Take over this thread.`
73
72
 
74
73
  The message body is static: it carries no starter copy, routing fields, or handoff ID. The playbook routes by thread metadata, claims the current session, and reads the visible starter and human replies through thread history. The nudge supplies no missing input or approval. A takeover turn with nothing to report ends with `HEARTBEAT_OK`; the deterministic gateway probe confirmed that `NO_REPLY` still triggers isolated finalization on this path.
75
74
 
76
- The plugin starts the thread session and does nothing after that. Alcode completion uses OpenClaw's own completion path.
75
+ The plugin starts the thread session and does nothing after that. `aligndev code` completion uses OpenClaw's own completion path.
77
76
 
78
77
  Each takeover turn gets the regular agent budget from `agents.defaults.timeoutSeconds`, including the 48-hour OpenClaw default and the unlimited `0` value.
79
78
 
@@ -111,7 +110,7 @@ npm run lint --workspace @alignfirst/service-openclaw-plugin
111
110
  ```
112
111
 
113
112
  The ordinary test command excludes the real-gateway suite. To exercise the package as an external
114
- plugin against the pinned OpenClaw 2026.9.5 runtime, including Slack/Discord delivery, concurrent human messages, duplicate starts, same-session continuation, and abrupt restart recovery:
113
+ plugin against the OpenClaw runtime pinned in the root lockfile, including Slack/Discord delivery, concurrent human messages, duplicate starts, same-session continuation, and abrupt restart recovery:
115
114
 
116
115
  ```bash
117
116
  KEEP_THREAD_HANDOFF_ARTIFACTS=1 npm run test:integration --workspace @alignfirst/service-openclaw-plugin
@@ -1,5 +1,6 @@
1
1
  import { registerThreadHandoffCli } from "./cli.js";
2
- import { createReceiptCoordinator } from "./receipts.js";
2
+ import { processShared } from "./process-shared.js";
3
+ import { createReceiptCache, createReceiptCoordinator } from "./receipts.js";
3
4
  import { createRunIdCache } from "./run-ids.js";
4
5
  import { createHandoffService } from "./service.js";
5
6
  import { createHandoffStore, resolveDatabasePath } from "./state.js";
@@ -16,8 +17,13 @@ export function registerThreadHandoff(api) {
16
17
  store ??= createHandoffStore(api.runtime.state.resolveStateDir());
17
18
  return store;
18
19
  };
19
- const receipts = createReceiptCoordinator({ configuration, getStore, logger: api.logger });
20
- const runIds = createRunIdCache();
20
+ const receipts = createReceiptCoordinator({
21
+ configuration,
22
+ getStore,
23
+ logger: api.logger,
24
+ cache: processShared("receipt-cache/v2", createReceiptCache),
25
+ });
26
+ const runIds = processShared("run-ids/v1", () => createRunIdCache());
21
27
  const service = createHandoffService({
22
28
  runtime: api.runtime,
23
29
  configuration,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns the value stored under `name` for the whole gateway process, created on first use.
3
+ *
4
+ * Since 2026.9.8, OpenClaw evaluates this plugin more than once in one gateway process: a turn
5
+ * takes its tools from the gateway's registration and runs its tool hooks in its own. State that
6
+ * both a tool and a hook read must outlive a single registration.
7
+ */
8
+ export declare function processShared<T>(name: string, create: () => T): T;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns the value stored under `name` for the whole gateway process, created on first use.
3
+ *
4
+ * Since 2026.9.8, OpenClaw evaluates this plugin more than once in one gateway process: a turn
5
+ * takes its tools from the gateway's registration and runs its tool hooks in its own. State that
6
+ * both a tool and a hook read must outlive a single registration.
7
+ */
8
+ export function processShared(name, create) {
9
+ const slots = globalThis;
10
+ const key = Symbol.for(`@alignfirst/service-openclaw-plugin/${name}`);
11
+ if (!(key in slots))
12
+ slots[key] = create();
13
+ // The slot holds what `create` returned under this versioned name.
14
+ return slots[key];
15
+ }
@@ -1,6 +1,6 @@
1
1
  import type { OpenClawPluginToolContext, PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { HandoffStore } from "./state.js";
3
- import type { DeliveryReceipt, PluginConfiguration, ReceiptIdentity } from "./types.js";
3
+ import type { DeliveryReceipt, PluginConfiguration, ReceiptIdentity, SourceContext } from "./types.js";
4
4
  export interface ReceiptCoordinator {
5
5
  captureContext(context: OpenClawPluginToolContext): void;
6
6
  observe(event: ToolObservation, context: HookContext): void;
@@ -17,10 +17,27 @@ interface HookContext {
17
17
  sessionKey?: string;
18
18
  sessionId?: string;
19
19
  }
20
+ /**
21
+ * Captured contexts, threads created by a session and observation errors, shared by every
22
+ * registration of the plugin.
23
+ */
24
+ export interface ReceiptCache {
25
+ contexts: Map<string, CachedContext>;
26
+ createdThreads: Map<string, CachedEntry>;
27
+ observationErrors: Map<string, Error>;
28
+ }
29
+ export interface CachedEntry {
30
+ capturedAt: number;
31
+ }
32
+ export interface CachedContext extends CachedEntry {
33
+ source: SourceContext;
34
+ }
20
35
  export declare function createReceiptCoordinator(params: {
21
36
  configuration: PluginConfiguration;
22
37
  getStore: () => HandoffStore;
23
38
  logger: PluginLogger;
39
+ cache?: ReceiptCache;
24
40
  now?: () => number;
25
41
  }): ReceiptCoordinator;
42
+ export declare function createReceiptCache(): ReceiptCache;
26
43
  export {};
@@ -7,44 +7,41 @@ const RECEIPT_WAIT_MS = 1_000;
7
7
  const RECEIPT_POLL_MS = 25;
8
8
  export function createReceiptCoordinator(params) {
9
9
  const now = params.now ?? Date.now;
10
- const contexts = new Map();
11
- const observationErrors = new Map();
10
+ const { contexts, createdThreads, observationErrors } = params.cache ?? createReceiptCache();
12
11
  return {
13
12
  captureContext(context) {
14
13
  const source = readSourceContext(context, params.configuration);
15
14
  if (!source)
16
15
  return;
17
16
  contexts.set(contextKey(source.sessionKey, source.sessionId), { source, capturedAt: now() });
18
- pruneContexts(contexts, now());
17
+ pruneEntries(contexts, now());
19
18
  },
20
19
  observe(event, context) {
21
20
  const source = readCachedSource(contexts, context, now());
22
21
  if (!source || event.toolName !== "message" || event.error !== undefined)
23
22
  return;
24
23
  const surface = params.configuration.channelSurfaces[source.channelId];
25
- const receipt = parseDeliveryReceipt({
24
+ pruneEntries(createdThreads, now());
25
+ const result = parseObservation({
26
26
  event,
27
27
  source,
28
28
  surface,
29
+ isCreatedThread: (threadId) => createdThreads.has(lookupKey(source.sessionKey, source.sessionId, threadId)),
29
30
  now: now(),
30
31
  });
31
- if (!receipt)
32
+ if (!result)
32
33
  return;
33
- if (typeof receipt === "string") {
34
+ if (typeof result === "string") {
34
35
  const threadId = readObservedThreadId(event, surface);
35
- params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${receipt}`);
36
+ params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${result}`);
36
37
  return;
37
38
  }
38
- const key = lookupKey(receipt.sessionKey, receipt.sessionId, receipt.threadId);
39
- try {
40
- params.getStore().insertReceipt(receipt, now());
41
- observationErrors.delete(key);
42
- }
43
- catch (error) {
44
- const storedError = error instanceof Error ? error : new Error(String(error));
45
- observationErrors.set(key, storedError);
46
- params.logger.error(`thread-handoff receipt persistence failed: ${storedError.message}`);
39
+ if ("createdThreadId" in result) {
40
+ const key = lookupKey(source.sessionKey, source.sessionId, result.createdThreadId);
41
+ createdThreads.set(key, { capturedAt: now() });
42
+ return;
47
43
  }
44
+ persistReceipt({ ...params, receipt: result, observationErrors, now: now() });
48
45
  },
49
46
  async waitForReceipt(identity) {
50
47
  const key = lookupKey(identity.sourceSessionKey, identity.sourceSessionId, identity.threadId);
@@ -61,33 +58,52 @@ export function createReceiptCoordinator(params) {
61
58
  },
62
59
  };
63
60
  }
61
+ function persistReceipt(params) {
62
+ const { receipt, observationErrors } = params;
63
+ const key = lookupKey(receipt.sessionKey, receipt.sessionId, receipt.threadId);
64
+ try {
65
+ params.getStore().insertReceipt(receipt, params.now);
66
+ observationErrors.delete(key);
67
+ }
68
+ catch (error) {
69
+ const storedError = error instanceof Error ? error : new Error(String(error));
70
+ observationErrors.set(key, storedError);
71
+ params.logger.error(`thread-handoff receipt persistence failed: ${storedError.message}`);
72
+ }
73
+ }
74
+ export function createReceiptCache() {
75
+ return { contexts: new Map(), createdThreads: new Map(), observationErrors: new Map() };
76
+ }
64
77
  function readCachedSource(contexts, context, now) {
65
78
  const sessionKey = nonempty(context.sessionKey);
66
79
  const sessionId = nonempty(context.sessionId);
67
80
  if (!sessionKey || !sessionId)
68
81
  return;
69
- pruneContexts(contexts, now);
82
+ pruneEntries(contexts, now);
70
83
  return contexts.get(contextKey(sessionKey, sessionId))?.source;
71
84
  }
72
- function pruneContexts(contexts, now) {
73
- for (const [key, value] of contexts) {
85
+ function pruneEntries(entries, now) {
86
+ for (const [key, value] of entries) {
74
87
  if (value.capturedAt + RECEIPT_TTL_MS <= now)
75
- contexts.delete(key);
88
+ entries.delete(key);
76
89
  }
77
- while (contexts.size > CONTEXT_LIMIT) {
78
- const oldest = contexts.keys().next().value;
90
+ while (entries.size > CONTEXT_LIMIT) {
91
+ const oldest = entries.keys().next().value;
79
92
  if (typeof oldest !== "string")
80
93
  return;
81
- contexts.delete(oldest);
94
+ entries.delete(oldest);
82
95
  }
83
96
  }
84
- function parseDeliveryReceipt(params) {
85
- if (params.surface === "slack" && params.event.params.action === "send") {
97
+ function parseObservation(params) {
98
+ const action = params.event.params.action;
99
+ if (params.surface === "slack" && action === "send")
86
100
  return parseSlackReceipt(params);
87
- }
88
- if (params.surface === "discord" && params.event.params.action === "thread-create") {
89
- return parseDiscordReceipt(params);
90
- }
101
+ if (params.surface !== "discord")
102
+ return;
103
+ if (action === "thread-create")
104
+ return parseDiscordThreadCreation(params);
105
+ if (action === "thread-reply")
106
+ return parseDiscordStarterReply(params);
91
107
  return;
92
108
  }
93
109
  function parseSlackReceipt(params) {
@@ -136,9 +152,9 @@ function parseSlackReceipt(params) {
136
152
  now: params.now,
137
153
  });
138
154
  }
139
- function parseDiscordReceipt(params) {
155
+ /** A thread this session created in its own channel, anchored on a message. Not a receipt. */
156
+ function parseDiscordThreadCreation(params) {
140
157
  const { event, source } = params;
141
- const starterText = readStarter(event.params);
142
158
  const destination = readDestination(event.params);
143
159
  const anchorMessageId = nonempty(event.params.messageId);
144
160
  const details = readResultDetails(event.result);
@@ -146,8 +162,6 @@ function parseDiscordReceipt(params) {
146
162
  const threadId = nonempty(thread?.id);
147
163
  if (!threadId)
148
164
  return "missingThread";
149
- if (starterText === undefined)
150
- return "missingStarter";
151
165
  if (!anchorMessageId)
152
166
  return "missingMessageId";
153
167
  const returnedParent = nonempty(thread?.parent_id) ?? nonempty(thread?.parentId);
@@ -162,19 +176,50 @@ function parseDiscordReceipt(params) {
162
176
  return "partialDelivery";
163
177
  if (!accountMatches(event.params, source.accountId))
164
178
  return "accountMismatch";
179
+ return { createdThreadId: threadId };
180
+ }
181
+ /** The starter posted by `thread-reply` into a thread this session created. */
182
+ function parseDiscordStarterReply(params) {
183
+ const { event, source } = params;
184
+ const threadId = readReplyThreadId(event.params);
185
+ if (!threadId)
186
+ return "missingThread";
187
+ const starterText = readStarter(event.params);
188
+ if (starterText === undefined)
189
+ return "missingStarter";
190
+ if (!params.isCreatedThread(threadId))
191
+ return "unknownThread";
192
+ const details = readResultDetails(event.result);
193
+ if (details?.ok !== true)
194
+ return "notSent";
195
+ if (details.partial === true)
196
+ return "partialDelivery";
197
+ const result = asRecord(details.result);
198
+ const returnedChannelId = nonempty(result?.channelId);
199
+ if (returnedChannelId !== undefined && returnedChannelId !== threadId)
200
+ return "threadMismatch";
201
+ if (!accountMatches(event.params, source.accountId))
202
+ return "accountMismatch";
165
203
  return createReceipt({
166
204
  source,
167
205
  threadId,
168
206
  starterText,
207
+ starterMessageId: nonempty(result?.messageId),
169
208
  toolCallId: event.toolCallId,
170
209
  now: params.now,
171
210
  });
172
211
  }
212
+ /** Bundled Discord replies into `threadId`, or into the target when it is absent. */
213
+ function readReplyThreadId(params) {
214
+ return nonempty(params.threadId) ?? readDestination(params)?.replace(/^channel:/i, "");
215
+ }
173
216
  function readObservedThreadId(event, surface) {
174
217
  if (surface === "slack")
175
218
  return nonempty(event.params.threadId);
176
219
  if (surface !== "discord")
177
220
  return;
221
+ if (event.params.action === "thread-reply")
222
+ return readReplyThreadId(event.params);
178
223
  const details = readResultDetails(event.result);
179
224
  const thread = asRecord(details?.thread);
180
225
  return nonempty(thread?.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alignfirst/service-openclaw-plugin",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "OpenClaw capabilities for the AlignFirst Dev Kit.",
5
5
  "keywords": [
6
6
  "alignfirst",
@@ -43,9 +43,9 @@
43
43
  "typebox": "~1.3.23"
44
44
  },
45
45
  "devDependencies": {
46
- "@alignfirst/openclaw-channel-mock-core": "0.10.1",
46
+ "@alignfirst/openclaw-channel-mock-core": "0.11.0",
47
47
  "@types/node": "~26.5.0",
48
- "openclaw": "~2026.9.3",
48
+ "openclaw": "~2026.9.8",
49
49
  "rimraf": "~6.1.3",
50
50
  "typescript": "~7.0.2",
51
51
  "vitest": "~5.0.1"