@alignfirst/service-openclaw-plugin 0.3.1
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/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/thread-handoff/cli.d.ts +5 -0
- package/dist/thread-handoff/cli.js +89 -0
- package/dist/thread-handoff/dispatch.d.ts +15 -0
- package/dist/thread-handoff/dispatch.js +75 -0
- package/dist/thread-handoff/errors.d.ts +6 -0
- package/dist/thread-handoff/errors.js +16 -0
- package/dist/thread-handoff/index.d.ts +4 -0
- package/dist/thread-handoff/index.js +70 -0
- package/dist/thread-handoff/receipts.d.ts +26 -0
- package/dist/thread-handoff/receipts.js +239 -0
- package/dist/thread-handoff/routing.d.ts +18 -0
- package/dist/thread-handoff/routing.js +147 -0
- package/dist/thread-handoff/run-ids.d.ts +9 -0
- package/dist/thread-handoff/run-ids.js +39 -0
- package/dist/thread-handoff/service.d.ts +21 -0
- package/dist/thread-handoff/service.js +159 -0
- package/dist/thread-handoff/state.d.ts +42 -0
- package/dist/thread-handoff/state.js +379 -0
- package/dist/thread-handoff/tool.d.ts +29 -0
- package/dist/thread-handoff/tool.js +168 -0
- package/dist/thread-handoff/types.d.ts +69 -0
- package/dist/thread-handoff/types.js +1 -0
- package/dist/thread-handoff/values.d.ts +3 -0
- package/dist/thread-handoff/values.js +14 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas MUR
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @alignfirst/service-openclaw-plugin
|
|
2
|
+
|
|
3
|
+
The OpenClaw gateway plugin of the [AlignFirst Dev Kit](https://alignfirst.paroi.tech/openclaw-dev-kit). It currently provides thread handoff: after a native message action delivers its visible starter, the plugin starts the regular channel-thread session through a reply run it dispatches itself. Delivery evidence and pending handoffs survive gateway restart in a plugin-owned SQLite database.
|
|
4
|
+
|
|
5
|
+
## Install and enable
|
|
6
|
+
|
|
7
|
+
Install the package through OpenClaw's normal external-plugin procedure, enable plugin ID
|
|
8
|
+
`alignfirst-service`, and explicitly allow the optional `thread_handoff` tool:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"plugins": {
|
|
13
|
+
"allow": ["alignfirst-service"],
|
|
14
|
+
"entries": { "alignfirst-service": { "enabled": true } }
|
|
15
|
+
},
|
|
16
|
+
"tools": { "allow": ["thread_handoff"] }
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The built-in channel mapping is Slack and Discord. Synthetic or renamed channel plugins can map
|
|
21
|
+
their IDs to the corresponding native contract:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"plugins": {
|
|
26
|
+
"entries": {
|
|
27
|
+
"alignfirst-service": {
|
|
28
|
+
"enabled": true,
|
|
29
|
+
"config": {
|
|
30
|
+
"channelSurfaces": {
|
|
31
|
+
"slack-mock": "slack",
|
|
32
|
+
"discord-mock": "discord"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Thread handoff contract
|
|
42
|
+
|
|
43
|
+
The plugin observes successful native `message` actions but never creates a thread itself.
|
|
44
|
+
|
|
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.
|
|
48
|
+
- `thread_handoff { "action": "start", "threadId": "..." }` returns `queued` or
|
|
49
|
+
`alreadyStarted`, plus the opaque handoff ID and canonical target session key.
|
|
50
|
+
|
|
51
|
+
The claim result has this contract:
|
|
52
|
+
|
|
53
|
+
| Status | Meaning |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `claimed` | The first claim, or a repeated claim by the same run. |
|
|
56
|
+
| `alreadyClaimed` | Another run owns the handoff. The result includes `claimedAt`. |
|
|
57
|
+
| `none` | No handoff matches an ordinary human turn in the target thread. |
|
|
58
|
+
|
|
59
|
+
The receiving turn calls `thread_handoff { "action": "claim" }` once before task effects. The tool resolves the handoff from the current thread session; an explicit handoff ID remains an optional API parameter.
|
|
60
|
+
|
|
61
|
+
Inputs are strict. Errors begin with a stable reason code: `unsupportedContext`,
|
|
62
|
+
`unverifiedThreadDelivery`, `conflictingHandoff`, `invalidTarget`, or
|
|
63
|
+
`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.
|
|
65
|
+
|
|
66
|
+
Starts are limited to distinct regular parent-channel sessions. DMs, group DMs, Slack Agent View,
|
|
67
|
+
ACP, subagent, cron, global/shared, already-threaded, and ambiguous cross-account routes are not
|
|
68
|
+
supported.
|
|
69
|
+
|
|
70
|
+
## Turn start and persistence
|
|
71
|
+
|
|
72
|
+
The plugin commits a pending record before dispatching `Take over this thread.` from `AlignFirst Service` as a reply run through the channel-inbound path. Immediate dispatch starts outside the calling tool turn's asynchronous context. Its plugin-built context sets the service display name without a human sender ID or command authority, and sets `WasMentioned: false`. These plugin-dispatched turns disable block streaming so their complete final payload reaches OpenClaw's durable outbound path. The reply run records the session's last route. The plugin's in-process nudge does not need an `openclaw` executable on the gateway's `PATH`.
|
|
73
|
+
|
|
74
|
+
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
|
+
|
|
76
|
+
The plugin starts the thread session and does nothing after that. Alcode completion uses OpenClaw's own completion path.
|
|
77
|
+
|
|
78
|
+
Each takeover turn gets the regular agent budget from `agents.defaults.timeoutSeconds`, including the 48-hour OpenClaw default and the unlimited `0` value.
|
|
79
|
+
|
|
80
|
+
The database is `<stateDir>/thread-handoff/state.sqlite`, where `stateDir` comes from
|
|
81
|
+
`api.runtime.state.resolveStateDir()`. It uses WAL, full synchronous durability, a `0700` directory,
|
|
82
|
+
and a `0600` database file. Receipts expire after one hour and are capped at 10,000 active entries.
|
|
83
|
+
Handoffs have a separate 10,000-record cap and do not expire automatically. The plugin scans
|
|
84
|
+
pending records at startup and every 30 seconds. It starts at most ten attempts, with at least 60
|
|
85
|
+
seconds after an attempt ends before the next one. A record still pending after the tenth attempt
|
|
86
|
+
stays claimable by the next human message in the thread. Claimed records remain as duplicate-start
|
|
87
|
+
protection; native OpenClaw recovery owns interrupted work after claim.
|
|
88
|
+
|
|
89
|
+
Use `openclaw thread-handoff list [--json]` to inspect handoffs with their attempt counts and
|
|
90
|
+
claimer identity. Use `openclaw thread-handoff receipts [--json]` to inspect active delivery
|
|
91
|
+
receipts without starter text. `openclaw thread-handoff retire <handoff-id>` removes a claimed
|
|
92
|
+
record; add `--force` for a pending record, typically a parked one.
|
|
93
|
+
|
|
94
|
+
Opening a database created by plugin 0.2.0 migrates it automatically to schema 2. The migration
|
|
95
|
+
preserves pending attempt history and claimed records. To downgrade to 0.2.0, stop the gateway and delete `<stateDir>/thread-handoff/state.sqlite`. Deletion loses pending handoffs.
|
|
96
|
+
|
|
97
|
+
For a backup, stop the gateway and let the plugin close/checkpoint its connection, then copy the
|
|
98
|
+
database together with any WAL/SHM crash-state files; alternatively use a SQLite-consistent backup.
|
|
99
|
+
Do not copy only the main file from a live gateway. Retain pending work. Retire only finished managed
|
|
100
|
+
handoffs, because deleting a claimed record also deletes its duplicate-start protection.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
`src/index.ts` defines the plugin identity and configuration schema. `src/thread-handoff/` owns handoff registration, tools, hooks, recovery and persistence. Additional assistant features can register alongside it through the root entry point.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm run build --workspace @alignfirst/service-openclaw-plugin
|
|
108
|
+
npm test --workspace @alignfirst/service-openclaw-plugin
|
|
109
|
+
npm run typecheck --workspace @alignfirst/service-openclaw-plugin
|
|
110
|
+
npm run lint --workspace @alignfirst/service-openclaw-plugin
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The ordinary test command excludes the real-gateway suite. To exercise the package as an external
|
|
114
|
+
plugin against the pinned OpenClaw 2026.9.4 runtime, including Slack/Discord delivery, concurrent human messages, duplicate starts, same-session continuation, and abrupt restart recovery:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
KEEP_THREAD_HANDOFF_ARTIFACTS=1 npm run test:integration --workspace @alignfirst/service-openclaw-plugin
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Retained fixtures are written under `/tmp/thread-handoff-*` with gateway logs, provider requests,
|
|
121
|
+
plugin SQLite state, configuration, and workspace files. Omit the environment variable for normal
|
|
122
|
+
automatic cleanup.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare const _default: Omit<{
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
kind?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["kind"];
|
|
6
|
+
configSchema?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema | (() => import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema);
|
|
7
|
+
reload?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["reload"];
|
|
8
|
+
nodeHostCommands?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["nodeHostCommands"];
|
|
9
|
+
securityAuditCollectors?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["securityAuditCollectors"];
|
|
10
|
+
register: NonNullable<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["register"]>;
|
|
11
|
+
}, "configSchema"> & {
|
|
12
|
+
configSchema: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema;
|
|
13
|
+
};
|
|
14
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { buildJsonPluginConfigSchema, definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { DEFAULT_CHANNEL_SURFACES, registerThreadHandoff } from "./thread-handoff/index.js";
|
|
3
|
+
const configSchema = buildJsonPluginConfigSchema({
|
|
4
|
+
type: "object",
|
|
5
|
+
additionalProperties: false,
|
|
6
|
+
properties: {
|
|
7
|
+
channelSurfaces: {
|
|
8
|
+
type: "object",
|
|
9
|
+
additionalProperties: { enum: ["slack", "discord"] },
|
|
10
|
+
default: DEFAULT_CHANNEL_SURFACES,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
export default definePluginEntry({
|
|
15
|
+
id: "alignfirst-service",
|
|
16
|
+
name: "AlignFirst Service",
|
|
17
|
+
description: "OpenClaw capabilities for the AlignFirst Dev Kit.",
|
|
18
|
+
configSchema,
|
|
19
|
+
register: registerThreadHandoff,
|
|
20
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { DeliveryReceipt, HandoffRecord } from "./types.js";
|
|
3
|
+
export declare function registerThreadHandoffCli(api: OpenClawPluginApi): void;
|
|
4
|
+
export declare function renderHandoffs(records: HandoffRecord[], json: boolean): string;
|
|
5
|
+
export declare function renderReceipts(receipts: DeliveryReceipt[], json: boolean): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHandoffStore } from "./state.js";
|
|
2
|
+
export function registerThreadHandoffCli(api) {
|
|
3
|
+
api.registerCli(({ program }) => {
|
|
4
|
+
const command = program
|
|
5
|
+
.command("thread-handoff")
|
|
6
|
+
.description("Inspect and maintain durable thread handoffs");
|
|
7
|
+
command
|
|
8
|
+
.command("list")
|
|
9
|
+
.description("List pending and claimed handoffs")
|
|
10
|
+
.option("--json", "Print JSON")
|
|
11
|
+
.action((options) => listHandoffs(api, options.json === true));
|
|
12
|
+
command
|
|
13
|
+
.command("receipts")
|
|
14
|
+
.description("List active delivery receipts")
|
|
15
|
+
.option("--json", "Print JSON")
|
|
16
|
+
.action((options) => listReceipts(api, options.json === true));
|
|
17
|
+
command
|
|
18
|
+
.command("retire")
|
|
19
|
+
.description("Retire one claimed handoff")
|
|
20
|
+
.argument("<handoff-id>")
|
|
21
|
+
.option("--force", "Also retire a pending handoff")
|
|
22
|
+
.action((handoffId, options) => retireHandoff(api, handoffId, options.force === true));
|
|
23
|
+
}, {
|
|
24
|
+
descriptors: [
|
|
25
|
+
{
|
|
26
|
+
name: "thread-handoff",
|
|
27
|
+
description: "Inspect and maintain durable thread handoffs",
|
|
28
|
+
hasSubcommands: true,
|
|
29
|
+
machineOutput: ({ argv }) => argv.includes("--json"),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function listHandoffs(api, json) {
|
|
35
|
+
withStore(api, (store) => {
|
|
36
|
+
process.stdout.write(`${renderHandoffs(store.listHandoffs(), json)}\n`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function renderHandoffs(records, json) {
|
|
40
|
+
if (json) {
|
|
41
|
+
const projected = records.map(({ starterText: _starterText, ...record }) => record);
|
|
42
|
+
return JSON.stringify(projected, null, 2);
|
|
43
|
+
}
|
|
44
|
+
if (records.length === 0)
|
|
45
|
+
return "No managed handoffs.";
|
|
46
|
+
return records
|
|
47
|
+
.map((record) => `${record.handoffId}\t${record.state}\t${record.attemptCount} attempts\t${record.targetSessionKey}\t${record.createdAt}`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
|
50
|
+
function withStore(api, operation) {
|
|
51
|
+
const store = createHandoffStore(api.runtime.state.resolveStateDir());
|
|
52
|
+
try {
|
|
53
|
+
return operation(store);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
store.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function listReceipts(api, json) {
|
|
60
|
+
withStore(api, (store) => {
|
|
61
|
+
process.stdout.write(`${renderReceipts(store.listReceipts(Date.now()), json)}\n`);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export function renderReceipts(receipts, json) {
|
|
65
|
+
if (json) {
|
|
66
|
+
const projected = receipts.map((receipt) => ({
|
|
67
|
+
sessionKey: receipt.sessionKey,
|
|
68
|
+
sessionId: receipt.sessionId,
|
|
69
|
+
threadId: receipt.threadId,
|
|
70
|
+
...(receipt.starterMessageId ? { starterMessageId: receipt.starterMessageId } : {}),
|
|
71
|
+
createdAt: receipt.createdAt,
|
|
72
|
+
expiresAt: receipt.expiresAt,
|
|
73
|
+
}));
|
|
74
|
+
return JSON.stringify(projected, null, 2);
|
|
75
|
+
}
|
|
76
|
+
if (receipts.length === 0)
|
|
77
|
+
return "No active receipts.";
|
|
78
|
+
return receipts
|
|
79
|
+
.map((receipt) => `${receipt.sessionKey}\t${receipt.threadId}\t${receipt.starterMessageId ?? "-"}\t${receipt.createdAt}\t${receipt.expiresAt}`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
function retireHandoff(api, handoffId, force) {
|
|
83
|
+
withStore(api, (store) => {
|
|
84
|
+
const retired = store.retireHandoff(handoffId.trim(), { force });
|
|
85
|
+
if (!retired)
|
|
86
|
+
throw new Error(`Unknown handoff: ${handoffId}`);
|
|
87
|
+
process.stdout.write(`Retired ${handoffId}.\n`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { TurnRequest } from "./types.js";
|
|
3
|
+
export declare function dispatchTurn(params: {
|
|
4
|
+
runtime: OpenClawPluginApi["runtime"];
|
|
5
|
+
logger: PluginLogger;
|
|
6
|
+
request: TurnRequest;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
export declare function buildTurnContext(request: TurnRequest): Record<string, unknown>;
|
|
9
|
+
export declare function buildLastRoute(request: TurnRequest): {
|
|
10
|
+
sessionKey: string;
|
|
11
|
+
channel: string;
|
|
12
|
+
to: string;
|
|
13
|
+
accountId?: string;
|
|
14
|
+
threadId?: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readConversationId } from "./routing.js";
|
|
2
|
+
import { errorMessage } from "./values.js";
|
|
3
|
+
export async function dispatchTurn(params) {
|
|
4
|
+
const { runtime, logger, request } = params;
|
|
5
|
+
const cfg = runtime.config.current();
|
|
6
|
+
await runtime.channel.inbound.dispatchReply({
|
|
7
|
+
// The dispatcher only reads configuration, while the runtime exposes it as readonly.
|
|
8
|
+
cfg: cfg,
|
|
9
|
+
channel: request.channelId,
|
|
10
|
+
...(request.route.accountId ? { accountId: request.route.accountId } : {}),
|
|
11
|
+
agentId: request.agentId,
|
|
12
|
+
routeSessionKey: request.sessionKey,
|
|
13
|
+
storePath: runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
14
|
+
agentId: request.agentId,
|
|
15
|
+
}),
|
|
16
|
+
ctxPayload: runtime.channel.reply.finalizeInboundContext(buildTurnContext(request)),
|
|
17
|
+
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
18
|
+
dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
19
|
+
delivery: {
|
|
20
|
+
// Owning the key prevents core from falling back to the context thread on Discord.
|
|
21
|
+
durable: { to: request.route.to, threadId: request.route.threadId, replyToId: null },
|
|
22
|
+
deliver: async () => ({ visibleReplySent: false }),
|
|
23
|
+
onError: (error, info) => logger.warn(`thread-handoff delivery failed (${info.kind}): ${errorMessage(error)}`),
|
|
24
|
+
},
|
|
25
|
+
replyPipeline: {},
|
|
26
|
+
replyOptions: { disableBlockStreaming: true },
|
|
27
|
+
record: {
|
|
28
|
+
updateLastRoute: buildLastRoute(request),
|
|
29
|
+
onRecordError: (error) => logger.warn(`thread-handoff session record failed: ${errorMessage(error)}`),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function buildTurnContext(request) {
|
|
34
|
+
const conversationId = readConversationId(request.route.to);
|
|
35
|
+
if (!conversationId)
|
|
36
|
+
throw new Error(`Invalid delivery target: ${request.route.to}`);
|
|
37
|
+
const messageThreadId = request.surface === "slack" ? request.route.threadId : conversationId;
|
|
38
|
+
return {
|
|
39
|
+
Body: request.message,
|
|
40
|
+
BodyForAgent: request.message,
|
|
41
|
+
RawBody: request.message,
|
|
42
|
+
CommandBody: "",
|
|
43
|
+
CommandInterpretationSuppressed: true,
|
|
44
|
+
CommandAuthorized: false,
|
|
45
|
+
SessionKey: request.sessionKey,
|
|
46
|
+
AccountId: request.route.accountId,
|
|
47
|
+
Provider: request.channelId,
|
|
48
|
+
Surface: request.channelId,
|
|
49
|
+
OriginatingChannel: request.channelId,
|
|
50
|
+
From: request.route.to,
|
|
51
|
+
To: request.route.to,
|
|
52
|
+
OriginatingTo: request.route.to,
|
|
53
|
+
NativeChannelId: request.surface === "slack" ? request.parentConversationId : conversationId,
|
|
54
|
+
ChatType: "group",
|
|
55
|
+
GroupChannel: request.parentConversationId,
|
|
56
|
+
ConversationLabel: request.parentConversationId,
|
|
57
|
+
GroupSubject: request.parentConversationId,
|
|
58
|
+
MessageThreadId: messageThreadId,
|
|
59
|
+
ThreadParentId: request.parentConversationId,
|
|
60
|
+
WasMentioned: false,
|
|
61
|
+
SenderName: "AlignFirst Service",
|
|
62
|
+
MessageSid: request.messageId,
|
|
63
|
+
MessageSidFull: request.messageId,
|
|
64
|
+
Timestamp: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function buildLastRoute(request) {
|
|
68
|
+
return {
|
|
69
|
+
sessionKey: request.sessionKey,
|
|
70
|
+
channel: request.route.channel,
|
|
71
|
+
to: request.route.to,
|
|
72
|
+
...(request.route.accountId ? { accountId: request.route.accountId } : {}),
|
|
73
|
+
...(request.route.threadId ? { threadId: request.route.threadId } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class HandoffError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
causeCode;
|
|
4
|
+
constructor(code, message, cause) {
|
|
5
|
+
super(message, { cause });
|
|
6
|
+
this.name = "HandoffError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.causeCode = readCauseCode(cause);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function readCauseCode(cause) {
|
|
12
|
+
if (!cause || typeof cause !== "object" || Array.isArray(cause))
|
|
13
|
+
return;
|
|
14
|
+
const code = Reflect.get(cause, "code");
|
|
15
|
+
return typeof code === "string" ? code : undefined;
|
|
16
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { PluginConfiguration } from "./types.js";
|
|
3
|
+
export declare const DEFAULT_CHANNEL_SURFACES: PluginConfiguration["channelSurfaces"];
|
|
4
|
+
export declare function registerThreadHandoff(api: OpenClawPluginApi): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { registerThreadHandoffCli } from "./cli.js";
|
|
2
|
+
import { createReceiptCoordinator } from "./receipts.js";
|
|
3
|
+
import { createRunIdCache } from "./run-ids.js";
|
|
4
|
+
import { createHandoffService } from "./service.js";
|
|
5
|
+
import { createHandoffStore, resolveDatabasePath } from "./state.js";
|
|
6
|
+
import { createThreadHandoffTool } from "./tool.js";
|
|
7
|
+
import { asRecord } from "./values.js";
|
|
8
|
+
export const DEFAULT_CHANNEL_SURFACES = {
|
|
9
|
+
slack: "slack",
|
|
10
|
+
discord: "discord",
|
|
11
|
+
};
|
|
12
|
+
export function registerThreadHandoff(api) {
|
|
13
|
+
const configuration = readConfiguration(api.pluginConfig);
|
|
14
|
+
let store;
|
|
15
|
+
const getStore = () => {
|
|
16
|
+
store ??= createHandoffStore(api.runtime.state.resolveStateDir());
|
|
17
|
+
return store;
|
|
18
|
+
};
|
|
19
|
+
const receipts = createReceiptCoordinator({ configuration, getStore, logger: api.logger });
|
|
20
|
+
const runIds = createRunIdCache();
|
|
21
|
+
const service = createHandoffService({
|
|
22
|
+
runtime: api.runtime,
|
|
23
|
+
configuration,
|
|
24
|
+
getStore,
|
|
25
|
+
logger: api.logger,
|
|
26
|
+
});
|
|
27
|
+
api.registerTool((context) => createThreadHandoffTool({ context, configuration, receipts, runIds, getStore, service }), { name: "thread_handoff", optional: true });
|
|
28
|
+
api.on("after_tool_call", (event, context) => {
|
|
29
|
+
receipts.observe({
|
|
30
|
+
toolName: event.toolName,
|
|
31
|
+
params: asRecord(event.params) ?? {},
|
|
32
|
+
...(event.toolCallId ? { toolCallId: event.toolCallId } : {}),
|
|
33
|
+
...(event.result !== undefined ? { result: event.result } : {}),
|
|
34
|
+
...(event.error ? { error: event.error } : {}),
|
|
35
|
+
}, context);
|
|
36
|
+
});
|
|
37
|
+
api.on("before_tool_call", (event, context) => {
|
|
38
|
+
if (event.toolName === "thread_handoff")
|
|
39
|
+
runIds.remember(context);
|
|
40
|
+
});
|
|
41
|
+
registerThreadHandoffCli(api);
|
|
42
|
+
if (api.registrationMode !== "full")
|
|
43
|
+
return;
|
|
44
|
+
api.registerService({
|
|
45
|
+
id: "thread-handoff-recovery",
|
|
46
|
+
async start() {
|
|
47
|
+
await service.start();
|
|
48
|
+
api.logger.info(`thread-handoff persistence ready at ${resolveDatabasePath(api.runtime.state.resolveStateDir())}`);
|
|
49
|
+
},
|
|
50
|
+
async stop() {
|
|
51
|
+
// `service.stop()` guarantees no detached completion calls `getStore()` afterwards; such a
|
|
52
|
+
// call would reopen the database behind the close below.
|
|
53
|
+
await service.stop();
|
|
54
|
+
store?.close();
|
|
55
|
+
store = undefined;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function readConfiguration(value) {
|
|
60
|
+
const record = asRecord(value);
|
|
61
|
+
const configured = asRecord(record?.channelSurfaces);
|
|
62
|
+
const channelSurfaces = {};
|
|
63
|
+
for (const [channel, surface] of Object.entries(configured ?? DEFAULT_CHANNEL_SURFACES)) {
|
|
64
|
+
if (surface !== "slack" && surface !== "discord") {
|
|
65
|
+
throw new Error(`Invalid thread-handoff surface for channel ${channel}.`);
|
|
66
|
+
}
|
|
67
|
+
channelSurfaces[channel] = surface;
|
|
68
|
+
}
|
|
69
|
+
return { channelSurfaces };
|
|
70
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OpenClawPluginToolContext, PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { HandoffStore } from "./state.js";
|
|
3
|
+
import type { DeliveryReceipt, PluginConfiguration, ReceiptIdentity } from "./types.js";
|
|
4
|
+
export interface ReceiptCoordinator {
|
|
5
|
+
captureContext(context: OpenClawPluginToolContext): void;
|
|
6
|
+
observe(event: ToolObservation, context: HookContext): void;
|
|
7
|
+
waitForReceipt(identity: ReceiptIdentity): Promise<DeliveryReceipt | undefined>;
|
|
8
|
+
}
|
|
9
|
+
interface ToolObservation {
|
|
10
|
+
toolName: string;
|
|
11
|
+
params: Record<string, unknown>;
|
|
12
|
+
toolCallId?: string;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
interface HookContext {
|
|
17
|
+
sessionKey?: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function createReceiptCoordinator(params: {
|
|
21
|
+
configuration: PluginConfiguration;
|
|
22
|
+
getStore: () => HandoffStore;
|
|
23
|
+
logger: PluginLogger;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}): ReceiptCoordinator;
|
|
26
|
+
export {};
|