@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
|
|
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.
|
|
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
|
|
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 {
|
|
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({
|
|
20
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
32
|
+
if (!result)
|
|
32
33
|
return;
|
|
33
|
-
if (typeof
|
|
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=${
|
|
36
|
+
params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${result}`);
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
82
|
+
pruneEntries(contexts, now);
|
|
70
83
|
return contexts.get(contextKey(sessionKey, sessionId))?.source;
|
|
71
84
|
}
|
|
72
|
-
function
|
|
73
|
-
for (const [key, value] of
|
|
85
|
+
function pruneEntries(entries, now) {
|
|
86
|
+
for (const [key, value] of entries) {
|
|
74
87
|
if (value.capturedAt + RECEIPT_TTL_MS <= now)
|
|
75
|
-
|
|
88
|
+
entries.delete(key);
|
|
76
89
|
}
|
|
77
|
-
while (
|
|
78
|
-
const oldest =
|
|
90
|
+
while (entries.size > CONTEXT_LIMIT) {
|
|
91
|
+
const oldest = entries.keys().next().value;
|
|
79
92
|
if (typeof oldest !== "string")
|
|
80
93
|
return;
|
|
81
|
-
|
|
94
|
+
entries.delete(oldest);
|
|
82
95
|
}
|
|
83
96
|
}
|
|
84
|
-
function
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
46
|
+
"@alignfirst/openclaw-channel-mock-core": "0.11.0",
|
|
47
47
|
"@types/node": "~26.5.0",
|
|
48
|
-
"openclaw": "~2026.9.
|
|
48
|
+
"openclaw": "~2026.9.8",
|
|
49
49
|
"rimraf": "~6.1.3",
|
|
50
50
|
"typescript": "~7.0.2",
|
|
51
51
|
"vitest": "~5.0.1"
|