@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.
@@ -0,0 +1,379 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { HandoffError } from "./errors.js";
5
+ import { errorMessage } from "./values.js";
6
+ const DATABASE_DIRECTORY_MODE = 0o700;
7
+ const DATABASE_FILE_MODE = 0o600;
8
+ const SCHEMA_VERSION = 2;
9
+ const STORE_CAPACITY = 10_000;
10
+ export function createHandoffStore(stateDir) {
11
+ const database = openDatabase(resolveDatabasePath(stateDir));
12
+ return createStoreOperations(database);
13
+ }
14
+ export function resolveDatabasePath(stateDir) {
15
+ return join(stateDir, "thread-handoff", "state.sqlite");
16
+ }
17
+ function openDatabase(databasePath) {
18
+ const directoryPath = dirname(databasePath);
19
+ mkdirSync(directoryPath, { recursive: true, mode: DATABASE_DIRECTORY_MODE });
20
+ chmodSync(directoryPath, DATABASE_DIRECTORY_MODE);
21
+ let database;
22
+ try {
23
+ database = new DatabaseSync(databasePath);
24
+ database.exec("PRAGMA busy_timeout = 2000;");
25
+ database.exec("PRAGMA journal_mode = WAL;");
26
+ database.exec("PRAGMA synchronous = FULL;");
27
+ initializeSchema(database);
28
+ chmodSync(databasePath, DATABASE_FILE_MODE);
29
+ return database;
30
+ }
31
+ catch (error) {
32
+ database?.close();
33
+ throw persistentStateError(`Could not open the thread-handoff database: ${errorMessage(error)}`, error);
34
+ }
35
+ }
36
+ function initializeSchema(database) {
37
+ const row = database.prepare("PRAGMA user_version").get();
38
+ if (row.user_version === SCHEMA_VERSION)
39
+ return;
40
+ if (row.user_version === 1) {
41
+ migrateSchema1(database);
42
+ return;
43
+ }
44
+ if (row.user_version !== 0) {
45
+ throw new Error(`Unsupported thread-handoff database schema ${row.user_version}.`);
46
+ }
47
+ inTransaction(database, () => {
48
+ database.exec(`
49
+ CREATE TABLE receipts (
50
+ receipt_key TEXT PRIMARY KEY,
51
+ source_session_key TEXT NOT NULL,
52
+ source_session_id TEXT NOT NULL,
53
+ thread_id TEXT NOT NULL,
54
+ expires_at INTEGER NOT NULL,
55
+ record_json TEXT NOT NULL
56
+ ) STRICT;
57
+ CREATE INDEX receipts_lookup_idx
58
+ ON receipts (source_session_key, source_session_id, thread_id, expires_at);
59
+ CREATE INDEX receipts_expiry_idx ON receipts (expires_at);
60
+ CREATE TABLE handoffs (
61
+ route_key TEXT PRIMARY KEY,
62
+ target_session_key TEXT NOT NULL UNIQUE,
63
+ handoff_id TEXT NOT NULL UNIQUE,
64
+ state TEXT NOT NULL CHECK (state IN ('pending', 'claimed')),
65
+ attempt_count INTEGER NOT NULL DEFAULT 0,
66
+ last_attempted_at INTEGER,
67
+ record_json TEXT NOT NULL
68
+ ) STRICT;
69
+ CREATE INDEX handoffs_pending_idx ON handoffs (state, attempt_count, last_attempted_at);
70
+ PRAGMA user_version = 2;
71
+ `);
72
+ });
73
+ }
74
+ function migrateSchema1(database) {
75
+ inTransaction(database, () => {
76
+ database.exec("ALTER TABLE handoffs RENAME COLUMN enqueue_count TO attempt_count;");
77
+ database.exec("ALTER TABLE handoffs RENAME COLUMN last_enqueued_at TO last_attempted_at;");
78
+ const rows = database
79
+ .prepare("SELECT route_key, record_json FROM handoffs")
80
+ .all();
81
+ const update = database.prepare("UPDATE handoffs SET record_json = ? WHERE route_key = ?");
82
+ for (const row of rows) {
83
+ const record = parseRecord(row.record_json, "handoff");
84
+ const { enqueueCount, lastEnqueuedAt, ...rest } = record;
85
+ update.run(JSON.stringify({
86
+ ...rest,
87
+ schemaVersion: 2,
88
+ attemptCount: enqueueCount,
89
+ ...(lastEnqueuedAt !== undefined ? { lastAttemptedAt: lastEnqueuedAt } : {}),
90
+ }), row.route_key);
91
+ }
92
+ database.exec("PRAGMA user_version = 2;");
93
+ });
94
+ }
95
+ function createStoreOperations(database) {
96
+ return {
97
+ insertReceipt: (receipt, now) => insertReceipt(database, receipt, now),
98
+ findReceipt: (identity, now) => findReceipt(database, identity, now),
99
+ listReceipts: (now) => listReceipts(database, now),
100
+ findHandoffByRoute: (routeKey) => findHandoffByRoute(database, routeKey),
101
+ insertHandoff: (record) => insertHandoff(database, record),
102
+ claimHandoff: (identity, now) => claimHandoff(database, identity, now),
103
+ recordAttempt: (routeKey, startedAt) => recordAttempt(database, routeKey, startedAt),
104
+ recordAttemptEnd: (routeKey, endedAt) => recordAttemptEnd(database, routeKey, endedAt),
105
+ listPending: (query) => listPending(database, query),
106
+ listHandoffs: () => listHandoffs(database),
107
+ retireHandoff: (handoffId, options) => retireHandoff(database, handoffId, options),
108
+ close: () => database.close(),
109
+ };
110
+ }
111
+ function insertReceipt(database, receipt, now) {
112
+ runStateOperation("store a delivery receipt", () => inTransaction(database, () => {
113
+ database.prepare("DELETE FROM receipts WHERE expires_at <= ?").run(now);
114
+ const existing = database
115
+ .prepare("SELECT receipt_key FROM receipts WHERE receipt_key = ?")
116
+ .get(receipt.receiptKey);
117
+ if (!existing)
118
+ assertCapacity(database, "receipts");
119
+ database
120
+ .prepare(`INSERT INTO receipts (
121
+ receipt_key, source_session_key, source_session_id, thread_id, expires_at, record_json
122
+ ) VALUES (?, ?, ?, ?, ?, ?)
123
+ ON CONFLICT(receipt_key) DO UPDATE SET
124
+ source_session_key = excluded.source_session_key,
125
+ source_session_id = excluded.source_session_id,
126
+ thread_id = excluded.thread_id,
127
+ expires_at = excluded.expires_at,
128
+ record_json = excluded.record_json`)
129
+ .run(receipt.receiptKey, receipt.sessionKey, receipt.sessionId, receipt.threadId, receipt.expiresAt, JSON.stringify(receipt));
130
+ }));
131
+ }
132
+ function findReceipt(database, identity, now) {
133
+ return runStateOperation("read a delivery receipt", () => {
134
+ database.prepare("DELETE FROM receipts WHERE expires_at <= ?").run(now);
135
+ const row = database
136
+ .prepare(`SELECT record_json FROM receipts
137
+ WHERE source_session_key = ? AND source_session_id = ? AND thread_id = ?
138
+ AND expires_at > ?
139
+ ORDER BY expires_at DESC LIMIT 1`)
140
+ .get(identity.sourceSessionKey, identity.sourceSessionId, identity.threadId, now);
141
+ return row ? parseReceipt(row.record_json) : undefined;
142
+ });
143
+ }
144
+ function findHandoffByRoute(database, routeKey) {
145
+ return runStateOperation("read a handoff", () => readHandoffByRoute(database, routeKey));
146
+ }
147
+ function insertHandoff(database, record) {
148
+ return runStateOperation("store a handoff", () => inTransaction(database, () => {
149
+ const existing = readHandoffByRoute(database, record.routeKey);
150
+ if (existing)
151
+ return { inserted: false, record: existing };
152
+ assertCapacity(database, "handoffs");
153
+ database
154
+ .prepare(`INSERT INTO handoffs (
155
+ route_key, target_session_key, handoff_id, state, attempt_count, last_attempted_at,
156
+ record_json
157
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
158
+ .run(record.routeKey, record.targetSessionKey, record.handoffId, record.state, record.attemptCount, record.lastAttemptedAt ?? null, JSON.stringify(record));
159
+ return { inserted: true, record };
160
+ }));
161
+ }
162
+ function readHandoffByRoute(database, routeKey) {
163
+ const row = database
164
+ .prepare("SELECT record_json FROM handoffs WHERE route_key = ?")
165
+ .get(routeKey);
166
+ return row ? parseHandoff(row.record_json) : undefined;
167
+ }
168
+ function claimHandoff(database, identity, now) {
169
+ return runStateOperation("claim a handoff", () => inTransaction(database, () => {
170
+ const row = findClaimRow(database, identity);
171
+ if (!row)
172
+ return { status: "none" };
173
+ const record = parseHandoff(row.record_json);
174
+ assertClaimIdentity(record, identity);
175
+ if (record.state === "claimed") {
176
+ return { status: sameClaimant(record, identity) ? "claimed" : "alreadyClaimed", record };
177
+ }
178
+ const claimed = {
179
+ ...record,
180
+ state: "claimed",
181
+ claimedAt: now,
182
+ claimedBy: {
183
+ sessionId: identity.sessionId,
184
+ ...(identity.runId !== undefined ? { runId: identity.runId } : {}),
185
+ },
186
+ };
187
+ const result = database
188
+ .prepare(`UPDATE handoffs SET state = 'claimed', record_json = ?
189
+ WHERE route_key = ? AND state = 'pending'`)
190
+ .run(JSON.stringify(claimed), record.routeKey);
191
+ if (result.changes === 1)
192
+ return { status: "claimed", record: claimed };
193
+ const current = readHandoffByRoute(database, record.routeKey);
194
+ if (!current)
195
+ return { status: "none" };
196
+ return {
197
+ status: sameClaimant(current, identity) ? "claimed" : "alreadyClaimed",
198
+ record: current,
199
+ };
200
+ }));
201
+ }
202
+ function sameClaimant(record, identity) {
203
+ if (!record.claimedBy)
204
+ return false;
205
+ if (identity.runId !== undefined)
206
+ return record.claimedBy.runId === identity.runId;
207
+ return record.claimedBy.runId === undefined && record.claimedBy.sessionId === identity.sessionId;
208
+ }
209
+ function findClaimRow(database, identity) {
210
+ if (identity.handoffId !== undefined) {
211
+ return database
212
+ .prepare("SELECT record_json FROM handoffs WHERE handoff_id = ?")
213
+ .get(identity.handoffId);
214
+ }
215
+ return database
216
+ .prepare("SELECT record_json FROM handoffs WHERE target_session_key = ?")
217
+ .get(identity.targetSessionKey);
218
+ }
219
+ function assertClaimIdentity(record, identity) {
220
+ if (record.targetSessionKey !== identity.targetSessionKey ||
221
+ record.agentId !== identity.agentId ||
222
+ record.accountId !== identity.accountId ||
223
+ (identity.handoffId !== undefined && record.handoffId !== identity.handoffId)) {
224
+ throw new HandoffError("invalidTarget", "This session cannot claim the requested handoff.");
225
+ }
226
+ }
227
+ function recordAttempt(database, routeKey, startedAt) {
228
+ return runStateOperation("record a handoff attempt", () => inTransaction(database, () => {
229
+ const current = readHandoffByRoute(database, routeKey);
230
+ if (current?.state !== "pending")
231
+ return current;
232
+ const updated = {
233
+ ...current,
234
+ attemptCount: current.attemptCount + 1,
235
+ lastAttemptedAt: startedAt,
236
+ };
237
+ database
238
+ .prepare(`UPDATE handoffs SET attempt_count = ?, last_attempted_at = ?, record_json = ?
239
+ WHERE route_key = ? AND state = 'pending'`)
240
+ .run(updated.attemptCount, startedAt, JSON.stringify(updated), routeKey);
241
+ return updated;
242
+ }));
243
+ }
244
+ function recordAttemptEnd(database, routeKey, endedAt) {
245
+ return runStateOperation("record a handoff attempt end", () => inTransaction(database, () => {
246
+ const current = readHandoffByRoute(database, routeKey);
247
+ if (current?.state !== "pending")
248
+ return current;
249
+ const updated = { ...current, lastAttemptedAt: endedAt };
250
+ database
251
+ .prepare(`UPDATE handoffs SET last_attempted_at = ?, record_json = ?
252
+ WHERE route_key = ? AND state = 'pending'`)
253
+ .run(endedAt, JSON.stringify(updated), routeKey);
254
+ return updated;
255
+ }));
256
+ }
257
+ function listPending(database, query) {
258
+ return runStateOperation("list pending handoffs", () => {
259
+ const rows = database
260
+ .prepare(`SELECT record_json FROM handoffs
261
+ WHERE state = 'pending' AND attempt_count < ?
262
+ AND (last_attempted_at IS NULL OR last_attempted_at <= ?)
263
+ ORDER BY COALESCE(last_attempted_at, 0), rowid`)
264
+ .all(query.maxAttempts, query.now - query.spacingMs);
265
+ return rows.map((row) => parseHandoff(row.record_json));
266
+ });
267
+ }
268
+ function listReceipts(database, now) {
269
+ return runStateOperation("list receipts", () => {
270
+ database.prepare("DELETE FROM receipts WHERE expires_at <= ?").run(now);
271
+ const rows = database
272
+ .prepare("SELECT record_json FROM receipts WHERE expires_at > ? ORDER BY rowid")
273
+ .all(now);
274
+ return rows.map((row) => parseReceipt(row.record_json));
275
+ });
276
+ }
277
+ function listHandoffs(database) {
278
+ return runStateOperation("list handoffs", () => {
279
+ const rows = database
280
+ .prepare("SELECT record_json FROM handoffs ORDER BY rowid")
281
+ .all();
282
+ return rows.map((row) => parseHandoff(row.record_json));
283
+ });
284
+ }
285
+ function retireHandoff(database, handoffId, options) {
286
+ return runStateOperation("retire a handoff", () => inTransaction(database, () => {
287
+ const row = database
288
+ .prepare("SELECT record_json FROM handoffs WHERE handoff_id = ?")
289
+ .get(handoffId);
290
+ if (!row)
291
+ return false;
292
+ const record = parseHandoff(row.record_json);
293
+ if (record.state === "pending" && !options.force) {
294
+ throw new HandoffError("conflictingHandoff", "This handoff is pending; pass --force to retire it anyway.");
295
+ }
296
+ const result = database.prepare("DELETE FROM handoffs WHERE handoff_id = ?").run(handoffId);
297
+ return result.changes === 1;
298
+ }));
299
+ }
300
+ function assertCapacity(database, table) {
301
+ const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
302
+ if (row.count < STORE_CAPACITY)
303
+ return;
304
+ const error = new Error(`${table} capacity ${STORE_CAPACITY} reached.`);
305
+ Object.assign(error, { code: "STORE_LIMIT_EXCEEDED" });
306
+ throw error;
307
+ }
308
+ function inTransaction(database, operation) {
309
+ database.exec("BEGIN IMMEDIATE;");
310
+ try {
311
+ const result = operation();
312
+ database.exec("COMMIT;");
313
+ return result;
314
+ }
315
+ catch (error) {
316
+ try {
317
+ database.exec("ROLLBACK;");
318
+ }
319
+ catch {
320
+ // Preserve the operation failure.
321
+ }
322
+ throw error;
323
+ }
324
+ }
325
+ function runStateOperation(description, operation) {
326
+ try {
327
+ return operation();
328
+ }
329
+ catch (error) {
330
+ if (error instanceof HandoffError)
331
+ throw error;
332
+ throw persistentStateError(`Could not ${description}: ${errorMessage(error)}`, error);
333
+ }
334
+ }
335
+ function persistentStateError(message, cause) {
336
+ return new HandoffError("unavailablePersistentState", message, cause);
337
+ }
338
+ function parseReceipt(json) {
339
+ const value = parseRecord(json, "delivery receipt");
340
+ if (value.schemaVersion !== 1 ||
341
+ typeof value.receiptKey !== "string" ||
342
+ typeof value.sessionKey !== "string" ||
343
+ typeof value.sessionId !== "string" ||
344
+ typeof value.threadId !== "string" ||
345
+ typeof value.starterText !== "string" ||
346
+ typeof value.expiresAt !== "number") {
347
+ throw new Error("Invalid delivery receipt record.");
348
+ }
349
+ return value;
350
+ }
351
+ function parseHandoff(json) {
352
+ const value = parseRecord(json, "handoff");
353
+ if (value.schemaVersion !== 2 ||
354
+ typeof value.routeKey !== "string" ||
355
+ typeof value.handoffId !== "string" ||
356
+ typeof value.targetSessionKey !== "string" ||
357
+ typeof value.agentId !== "string" ||
358
+ typeof value.sessionKey !== "string" ||
359
+ typeof value.threadId !== "string" ||
360
+ typeof value.starterText !== "string" ||
361
+ typeof value.attemptCount !== "number" ||
362
+ (value.state !== "pending" && value.state !== "claimed")) {
363
+ throw new Error("Invalid handoff record.");
364
+ }
365
+ return value;
366
+ }
367
+ function parseRecord(json, label) {
368
+ let value;
369
+ try {
370
+ value = JSON.parse(json);
371
+ }
372
+ catch (error) {
373
+ throw new Error(`Corrupt ${label} JSON.`, { cause: error });
374
+ }
375
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
376
+ throw new Error(`Invalid ${label} record.`);
377
+ }
378
+ return value;
379
+ }
@@ -0,0 +1,29 @@
1
+ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { Type } from "typebox";
3
+ import type { ReceiptCoordinator } from "./receipts.js";
4
+ import type { RunIdCache } from "./run-ids.js";
5
+ import type { HandoffService } from "./service.js";
6
+ import type { HandoffStore } from "./state.js";
7
+ import type { PluginConfiguration, ToolSuccess } from "./types.js";
8
+ export interface ThreadHandoffToolParams {
9
+ context: OpenClawPluginToolContext;
10
+ configuration: PluginConfiguration;
11
+ receipts: ReceiptCoordinator;
12
+ runIds: RunIdCache;
13
+ getStore: () => HandoffStore;
14
+ service: HandoffService;
15
+ now?: () => number;
16
+ }
17
+ export declare function createThreadHandoffTool(params: ThreadHandoffToolParams): {
18
+ name: string;
19
+ label: string;
20
+ description: string;
21
+ parameters: Type.TUnion<[Type.TObject<{
22
+ action: Type.TLiteral<"start">;
23
+ threadId: Type.TString;
24
+ }>, Type.TObject<{
25
+ action: Type.TLiteral<"claim">;
26
+ handoffId: Type.TOptional<Type.TString>;
27
+ }>]>;
28
+ execute(_toolCallId: string, input: unknown): Promise<import("openclaw/plugin-sdk/tool-results").AgentToolResult<ToolSuccess>>;
29
+ };
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { jsonResult } from "openclaw/plugin-sdk/tool-results";
3
+ import { Type } from "typebox";
4
+ import { HandoffError } from "./errors.js";
5
+ import { assertSupportedSource, createHandoffRecord, evidenceMatches, readSourceContext, resolveHandoffRoute, } from "./routing.js";
6
+ import { nonempty } from "./values.js";
7
+ const threadHandoffParameters = Type.Union([
8
+ Type.Object({
9
+ action: Type.Literal("start"),
10
+ threadId: Type.String({ minLength: 1 }),
11
+ }, { additionalProperties: false }),
12
+ Type.Object({
13
+ action: Type.Literal("claim"),
14
+ handoffId: Type.Optional(Type.String()),
15
+ }, { additionalProperties: false }),
16
+ ]);
17
+ export function createThreadHandoffTool(params) {
18
+ params.receipts.captureContext(params.context);
19
+ return {
20
+ name: "thread_handoff",
21
+ label: "Thread handoff",
22
+ description: "Start a confirmed native thread session or claim its durable handoff before task work.",
23
+ parameters: threadHandoffParameters,
24
+ async execute(_toolCallId, input) {
25
+ try {
26
+ const result = await executeAction(params, parseInput(input));
27
+ return jsonResult(result);
28
+ }
29
+ catch (error) {
30
+ throw presentError(error);
31
+ }
32
+ },
33
+ };
34
+ }
35
+ async function executeAction(params, input) {
36
+ const source = readSourceContext(params.context, params.configuration);
37
+ if (!source) {
38
+ throw new HandoffError("unsupportedContext", "Trusted channel session context is unavailable.");
39
+ }
40
+ if (input.action === "claim")
41
+ return claimHandoff(params, source, input.handoffId);
42
+ return startHandoff(params, source, input.threadId);
43
+ }
44
+ function claimHandoff(params, source, handoffId) {
45
+ const runId = params.runIds.read(source.sessionKey, source.sessionId);
46
+ const result = params.getStore().claimHandoff({
47
+ targetSessionKey: source.sessionKey,
48
+ agentId: source.agentId,
49
+ sessionId: source.sessionId,
50
+ ...(runId !== undefined ? { runId } : {}),
51
+ ...(source.accountId ? { accountId: source.accountId } : {}),
52
+ ...(handoffId ? { handoffId } : {}),
53
+ }, (params.now ?? Date.now)());
54
+ if (handoffId && result.status === "none") {
55
+ throw new HandoffError("invalidTarget", "The requested handoff does not exist.");
56
+ }
57
+ return {
58
+ status: result.status,
59
+ ...(result.status === "alreadyClaimed" && result.record?.claimedAt !== undefined
60
+ ? { claimedAt: result.record.claimedAt }
61
+ : {}),
62
+ };
63
+ }
64
+ async function startHandoff(params, source, threadId) {
65
+ const surface = assertSupportedSource(source, params.configuration);
66
+ const route = resolveHandoffRoute(source, threadId, surface);
67
+ return params.service.runForTarget(route.targetSessionKey, async () => {
68
+ const store = params.getStore();
69
+ const existing = store.findHandoffByRoute(route.routeKey);
70
+ if (existing) {
71
+ if (!sourceMatchesExisting(existing, source, threadId))
72
+ throw conflictingHandoff();
73
+ return resumeExisting(params.service, existing);
74
+ }
75
+ const receipt = await params.receipts.waitForReceipt({
76
+ sourceSessionKey: source.sessionKey,
77
+ sourceSessionId: source.sessionId,
78
+ threadId,
79
+ });
80
+ if (!receipt) {
81
+ throw new HandoffError("unverifiedThreadDelivery", "No confirmed starter delivery exists for this thread in the current session.");
82
+ }
83
+ const record = createHandoffRecord({
84
+ receipt,
85
+ route,
86
+ handoffId: randomUUID(),
87
+ createdAt: (params.now ?? Date.now)(),
88
+ });
89
+ const inserted = store.insertHandoff(record);
90
+ if (!inserted.inserted) {
91
+ if (!evidenceMatches(inserted.record, receipt))
92
+ throw conflictingHandoff();
93
+ return resumeExisting(params.service, inserted.record);
94
+ }
95
+ await params.service.startTurn(record);
96
+ return { status: "queued", handoffId: record.handoffId, sessionKey: record.targetSessionKey };
97
+ });
98
+ }
99
+ function sourceMatchesExisting(record, source, threadId) {
100
+ return (record.sessionKey === source.sessionKey &&
101
+ record.sessionId === source.sessionId &&
102
+ record.agentId === source.agentId &&
103
+ record.channelId === source.channelId &&
104
+ record.accountId === source.accountId &&
105
+ record.parentConversationId === source.parentConversationId &&
106
+ record.threadId === threadId);
107
+ }
108
+ function conflictingHandoff() {
109
+ return new HandoffError("conflictingHandoff", "This target already belongs to different delivery evidence.");
110
+ }
111
+ /** A record whose first attempt never started gets its seed now; otherwise nothing to redo. */
112
+ async function resumeExisting(service, record) {
113
+ if (record.state === "pending" && record.attemptCount === 0)
114
+ await service.startTurn(record);
115
+ return {
116
+ status: "alreadyStarted",
117
+ handoffId: record.handoffId,
118
+ sessionKey: record.targetSessionKey,
119
+ };
120
+ }
121
+ function parseInput(value) {
122
+ if (!value || typeof value !== "object" || Array.isArray(value))
123
+ return invalidInput();
124
+ const record = value;
125
+ const keys = Object.keys(record);
126
+ if (record.action === "start") {
127
+ if (keys.some((key) => key !== "action" && key !== "threadId"))
128
+ return invalidInput();
129
+ return { action: "start", threadId: requiredString(record.threadId) };
130
+ }
131
+ if (record.action === "claim") {
132
+ if (keys.some((key) => key !== "action" && key !== "handoffId"))
133
+ return invalidInput();
134
+ const handoffId = blankAsAbsent(record.handoffId);
135
+ return { action: "claim", ...(handoffId ? { handoffId } : {}) };
136
+ }
137
+ return invalidInput();
138
+ }
139
+ // A human turn without a seed sometimes sends `handoffId: ""` rather than omitting the field.
140
+ function blankAsAbsent(value) {
141
+ if (value === undefined)
142
+ return;
143
+ if (typeof value !== "string")
144
+ return invalidInput();
145
+ return nonempty(value);
146
+ }
147
+ function requiredString(value) {
148
+ const normalized = optionalString(value);
149
+ if (!normalized)
150
+ return invalidInput();
151
+ return normalized;
152
+ }
153
+ function optionalString(value) {
154
+ if (value === undefined)
155
+ return;
156
+ if (typeof value !== "string" || value.trim().length === 0)
157
+ return invalidInput();
158
+ return value.trim();
159
+ }
160
+ function invalidInput() {
161
+ throw new HandoffError("invalidTarget", "Invalid thread_handoff input.");
162
+ }
163
+ function presentError(error) {
164
+ if (!(error instanceof HandoffError))
165
+ return error instanceof Error ? error : new Error(String(error));
166
+ const cause = error.causeCode ? ` (${error.causeCode})` : "";
167
+ return new Error(`${error.code}${cause}: ${error.message}`, { cause: error });
168
+ }
@@ -0,0 +1,69 @@
1
+ export interface DeliveryRoute {
2
+ channel: string;
3
+ to: string;
4
+ accountId?: string;
5
+ threadId?: string;
6
+ }
7
+ export interface TurnRequest {
8
+ sessionKey: string;
9
+ agentId: string;
10
+ channelId: string;
11
+ surface: "slack" | "discord";
12
+ route: DeliveryRoute;
13
+ parentConversationId: string;
14
+ message: string;
15
+ messageId: string;
16
+ }
17
+ export interface SourceContext {
18
+ agentId: string;
19
+ sessionKey: string;
20
+ sessionId: string;
21
+ channelId: string;
22
+ accountId?: string;
23
+ parentConversationId: string;
24
+ deliveryContext?: DeliveryRoute;
25
+ }
26
+ export interface DeliveryReceipt extends SourceContext {
27
+ schemaVersion: 1;
28
+ receiptKey: string;
29
+ threadId: string;
30
+ starterMessageId?: string;
31
+ starterText: string;
32
+ toolCallId?: string;
33
+ createdAt: number;
34
+ expiresAt: number;
35
+ }
36
+ export interface HandoffRecord extends SourceContext {
37
+ schemaVersion: 2;
38
+ routeKey: string;
39
+ handoffId: string;
40
+ targetSessionKey: string;
41
+ threadId: string;
42
+ starterMessageId?: string;
43
+ starterText: string;
44
+ deliveryContext: DeliveryRoute;
45
+ createdAt: number;
46
+ attemptCount: number;
47
+ lastAttemptedAt?: number;
48
+ state: "pending" | "claimed";
49
+ claimedAt?: number;
50
+ claimedBy?: {
51
+ sessionId: string;
52
+ runId?: string;
53
+ };
54
+ }
55
+ export interface ReceiptIdentity {
56
+ sourceSessionKey: string;
57
+ sourceSessionId: string;
58
+ threadId: string;
59
+ }
60
+ export interface PluginConfiguration {
61
+ channelSurfaces: Record<string, "slack" | "discord">;
62
+ }
63
+ export interface ToolSuccess {
64
+ status: "queued" | "alreadyStarted" | "claimed" | "alreadyClaimed" | "none";
65
+ handoffId?: string;
66
+ sessionKey?: string;
67
+ claimedAt?: number;
68
+ }
69
+ export type HandoffErrorCode = "unsupportedContext" | "unverifiedThreadDelivery" | "conflictingHandoff" | "invalidTarget" | "unavailablePersistentState";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare function nonempty(value: unknown): string | undefined;
2
+ export declare function asRecord(value: unknown): Record<string, unknown> | undefined;
3
+ export declare function errorMessage(error: unknown): string;
@@ -0,0 +1,14 @@
1
+ export function nonempty(value) {
2
+ if (typeof value !== "string")
3
+ return;
4
+ const trimmed = value.trim();
5
+ return trimmed.length > 0 ? trimmed : undefined;
6
+ }
7
+ export function asRecord(value) {
8
+ return value && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : undefined;
11
+ }
12
+ export function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "alignfirst-service",
3
+ "name": "AlignFirst Service",
4
+ "description": "OpenClaw capabilities for the AlignFirst Dev Kit.",
5
+ "activation": { "onStartup": true },
6
+ "contracts": { "tools": ["thread_handoff"] },
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "channelSurfaces": {
12
+ "type": "object",
13
+ "additionalProperties": { "enum": ["slack", "discord"] },
14
+ "default": { "slack": "slack", "discord": "discord" }
15
+ }
16
+ }
17
+ }
18
+ }