@kin-tio/cli 0.6.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.
Files changed (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,167 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ function errorCode(error) {
5
+ if (!error || typeof error !== 'object' || !('code' in error))
6
+ return '';
7
+ return String(error.code ?? '');
8
+ }
9
+ export function processIsAlive(pid) {
10
+ if (!Number.isInteger(pid) || pid <= 0)
11
+ return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ return errorCode(error) === 'EPERM';
18
+ }
19
+ }
20
+ function readOwner(filePath) {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
23
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
24
+ return { pid: 0, token: '', createdAt: 0, invalid: true };
25
+ }
26
+ const candidate = parsed;
27
+ if (!Number.isInteger(candidate.pid) ||
28
+ typeof candidate.token !== 'string' ||
29
+ !candidate.token ||
30
+ !Number.isFinite(candidate.createdAt)) {
31
+ return { pid: 0, token: '', createdAt: 0, invalid: true };
32
+ }
33
+ return {
34
+ pid: Number(candidate.pid),
35
+ token: candidate.token,
36
+ createdAt: Number(candidate.createdAt),
37
+ };
38
+ }
39
+ catch (error) {
40
+ if (errorCode(error) === 'ENOENT')
41
+ return null;
42
+ return { pid: 0, token: '', createdAt: 0, invalid: true };
43
+ }
44
+ }
45
+ function writeExclusive(filePath, owner) {
46
+ const descriptor = fs.openSync(filePath, 'wx', 0o600);
47
+ try {
48
+ fs.writeFileSync(descriptor, `${JSON.stringify(owner)}\n`, 'utf8');
49
+ fs.fsyncSync(descriptor);
50
+ }
51
+ finally {
52
+ fs.closeSync(descriptor);
53
+ }
54
+ }
55
+ function unlinkIfOwned(filePath, token) {
56
+ const owner = readOwner(filePath);
57
+ if (!owner || owner.token !== token)
58
+ return false;
59
+ try {
60
+ fs.unlinkSync(filePath);
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ if (errorCode(error) === 'ENOENT')
65
+ return false;
66
+ throw error;
67
+ }
68
+ }
69
+ export class SingleInstanceLockError extends Error {
70
+ code;
71
+ owner;
72
+ constructor(message, { code = 'instance_locked', owner = null, } = {}) {
73
+ super(message);
74
+ this.name = 'SingleInstanceLockError';
75
+ this.code = code;
76
+ this.owner = owner;
77
+ }
78
+ }
79
+ export function acquireSingleInstanceLock({ filePath, pid = process.pid, clock = Date.now, isProcessAlive = processIsAlive, hasActiveDatabaseOwner = () => false, recoveryStaleMs = 30_000, }) {
80
+ if (!filePath)
81
+ throw new Error('Single-instance lock filePath is required');
82
+ const lockPath = path.resolve(filePath);
83
+ const recoveryPath = `${lockPath}.recovery`;
84
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
85
+ const token = randomUUID();
86
+ const owner = {
87
+ pid: Number(pid),
88
+ token,
89
+ createdAt: Number(clock()),
90
+ };
91
+ const recoveryOwner = readOwner(recoveryPath);
92
+ if (recoveryOwner) {
93
+ if (recoveryOwner.invalid) {
94
+ const age = Number(clock()) - fs.statSync(recoveryPath).mtimeMs;
95
+ if (age < Number(recoveryStaleMs)) {
96
+ throw new SingleInstanceLockError(`Single-instance lock recovery is already active: ${recoveryPath}`, { code: 'lock_recovery_in_progress', owner: recoveryOwner });
97
+ }
98
+ fs.unlinkSync(recoveryPath);
99
+ }
100
+ else if (isProcessAlive(Number(recoveryOwner.pid)) ||
101
+ hasActiveDatabaseOwner(recoveryOwner)) {
102
+ throw new SingleInstanceLockError(`Single-instance lock recovery is already active: ${recoveryPath}`, { code: 'lock_recovery_in_progress', owner: recoveryOwner });
103
+ }
104
+ else {
105
+ unlinkIfOwned(recoveryPath, recoveryOwner.token);
106
+ }
107
+ }
108
+ try {
109
+ writeExclusive(lockPath, owner);
110
+ }
111
+ catch (error) {
112
+ if (errorCode(error) !== 'EEXIST')
113
+ throw error;
114
+ const current = readOwner(lockPath);
115
+ if (current &&
116
+ !current.invalid &&
117
+ isProcessAlive(current.pid)) {
118
+ throw new SingleInstanceLockError(`Another Kintio instance is running with PID ${current.pid}`, { owner: current });
119
+ }
120
+ if (hasActiveDatabaseOwner(current)) {
121
+ throw new SingleInstanceLockError('The stale PID check failed but SQLite still reports an active owner', { code: 'database_owner_active', owner: current });
122
+ }
123
+ const recoveryToken = randomUUID();
124
+ const recovery = {
125
+ pid: Number(pid),
126
+ token: recoveryToken,
127
+ createdAt: Number(clock()),
128
+ };
129
+ try {
130
+ writeExclusive(recoveryPath, recovery);
131
+ }
132
+ catch (guardError) {
133
+ if (errorCode(guardError) === 'EEXIST') {
134
+ throw new SingleInstanceLockError('Another process is recovering the stale instance lock', { code: 'lock_recovery_in_progress', owner: readOwner(recoveryPath) });
135
+ }
136
+ throw guardError;
137
+ }
138
+ try {
139
+ const checked = readOwner(lockPath);
140
+ if (checked &&
141
+ !checked.invalid &&
142
+ isProcessAlive(Number(checked.pid))) {
143
+ throw new SingleInstanceLockError(`Another Kintio instance acquired the lock with PID ${checked.pid}`, { owner: checked });
144
+ }
145
+ if (checked && hasActiveDatabaseOwner(checked)) {
146
+ throw new SingleInstanceLockError('SQLite reports an active owner for the existing lock', { code: 'database_owner_active', owner: checked });
147
+ }
148
+ if (checked)
149
+ fs.unlinkSync(lockPath);
150
+ writeExclusive(lockPath, owner);
151
+ }
152
+ finally {
153
+ unlinkIfOwned(recoveryPath, recoveryToken);
154
+ }
155
+ }
156
+ let released = false;
157
+ return Object.freeze({
158
+ filePath: lockPath,
159
+ owner: Object.freeze({ ...owner }),
160
+ release() {
161
+ if (released)
162
+ return false;
163
+ released = true;
164
+ return unlinkIfOwned(lockPath, token);
165
+ },
166
+ });
167
+ }
@@ -0,0 +1,503 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { acquireSingleInstanceLock } from './runtime/single-instance-lock.js';
6
+ import { ensurePrivateDirectory } from './lib/private-directory.js';
7
+ import { CodexAgent, createCodexAppServer } from './services/codex-agent.js';
8
+ import { ConversationProcessor } from './services/conversation-processor.js';
9
+ import { cleanupStagedImageOrphans } from './services/image-stager.js';
10
+ import { WecomMediaGateway } from './services/media-gateway.js';
11
+ import { WecomApiClient } from './services/wecom-api.js';
12
+ import { WecomSync } from './services/wecom-sync.js';
13
+ import { WechatKfToolExecutor } from './mcp/wechat-kf-executor.js';
14
+ import { createWechatKfMcpServer } from './mcp/wechat-kf-server.js';
15
+ import { createIlinkMcpServer } from './mcp/ilink-server.js';
16
+ import { McpIpcHost } from './mcp/ipc-host.js';
17
+ import { IlinkSendExecutor } from './ilink/executor.js';
18
+ import { IlinkListenerManager } from './ilink/listener.js';
19
+ import { IlinkLoginManager } from './ilink/login-manager.js';
20
+ import { IlinkMediaGateway } from './ilink/media-gateway.js';
21
+ import { DEFAULT_ILINK_MEDIA_TIMEOUT_MS } from './ilink/media.js';
22
+ import { DEFAULT_ILINK_IMAGE_TIMEOUT_MS } from './ilink/inbound-image.js';
23
+ import { IlinkClient } from './ilink/protocol/client.js';
24
+ import { IlinkSecretBox } from './ilink/secret-box.js';
25
+ import { ConversationMemoryExecutor, createConversationMemoryMcpServer, } from './mcp/conversation-memory-server.js';
26
+ import { StatePersistence, StatePersistenceUnclosedError, } from './state/persistence.js';
27
+ function ilinkSecretGeneration(providerMessageId) {
28
+ return Number.parseInt(createHash('sha256').update(providerMessageId).digest('hex').slice(0, 12), 16);
29
+ }
30
+ function readOrCreatePrivateKey(filePath, label) {
31
+ const target = path.resolve(filePath);
32
+ ensurePrivateDirectory(path.dirname(target));
33
+ try {
34
+ const existing = fs.readFileSync(target, 'utf8').trim();
35
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(existing)) {
36
+ throw new Error(`${label} file is invalid`);
37
+ }
38
+ fs.chmodSync(target, 0o600);
39
+ return existing;
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
43
+ throw error;
44
+ }
45
+ }
46
+ const token = randomBytes(32).toString('base64url');
47
+ try {
48
+ fs.writeFileSync(target, `${token}\n`, { flag: 'wx', mode: 0o600 });
49
+ return token;
50
+ }
51
+ catch (error) {
52
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
53
+ return readOrCreatePrivateKey(target, label);
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ export async function createRuntime({ config, logger = console, }) {
59
+ if ((!config.wecom.api.enabled && !config.ilink.enabled) || !config.codex.enabled) {
60
+ logger.info('[runtime] message processing is disabled');
61
+ return {
62
+ messageProcessor: null,
63
+ async start() { },
64
+ stopAccepting() { },
65
+ async close() { },
66
+ async abort() { },
67
+ };
68
+ }
69
+ const enabledChannels = [
70
+ ...(config.wecom.api.enabled ? ['wechat_kf'] : []),
71
+ ...(config.ilink.enabled ? ['weixin_ilink'] : []),
72
+ ];
73
+ const instanceLock = acquireSingleInstanceLock({
74
+ filePath: config.state.lockFile,
75
+ hasActiveDatabaseOwner: () => StatePersistence.hasActiveWriter(config.state.databaseFile),
76
+ });
77
+ let persistence;
78
+ let cleanupTimer;
79
+ let ilinkOffers;
80
+ let mcpHost;
81
+ try {
82
+ persistence = new StatePersistence({ filePath: config.state.databaseFile });
83
+ const activePersistence = persistence;
84
+ const store = activePersistence.core;
85
+ const activeStore = store;
86
+ store.cleanup();
87
+ cleanupStagedImageOrphans(config.codex.imageTempDirectory);
88
+ cleanupTimer = setInterval(() => {
89
+ try {
90
+ activeStore.cleanup();
91
+ ilinkOffers?.cleanup();
92
+ }
93
+ catch (error) {
94
+ logger.error(`[cleanup] SQLite retention failed: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ }, 60 * 60 * 1000);
97
+ cleanupTimer.unref();
98
+ const startupInbound = store.recoverStartup().inbound.filter((record) => enabledChannels.includes(record.channel));
99
+ const apiClient = config.wecom.api.enabled
100
+ ? new WecomApiClient({
101
+ corpId: config.wecom.api.corpId,
102
+ kfSecret: config.wecom.api.kfSecret,
103
+ baseUrl: config.wecom.api.baseUrl,
104
+ timeoutMs: config.wecom.api.timeoutMs,
105
+ })
106
+ : undefined;
107
+ const mediaGateway = apiClient ? new WecomMediaGateway({ apiClient }) : undefined;
108
+ let ilinkLogin;
109
+ let ilinkListener;
110
+ let toolsUnavailable = false;
111
+ const wechatTools = apiClient && mediaGateway
112
+ ? new WechatKfToolExecutor({
113
+ store,
114
+ apiClient,
115
+ mediaGateway,
116
+ observeMs: config.wecom.api.observeMs,
117
+ logger,
118
+ ...(config.ilink.enabled ? {
119
+ ilinkOffers: {
120
+ offer(sessionToken) {
121
+ if (!ilinkLogin)
122
+ throw new Error('iLink login manager is unavailable');
123
+ return ilinkLogin.offer(sessionToken);
124
+ },
125
+ cancel(offerId) {
126
+ ilinkLogin?.cancel(offerId);
127
+ },
128
+ },
129
+ } : {}),
130
+ })
131
+ : undefined;
132
+ const ilinkSecretBox = config.ilink.enabled
133
+ ? new IlinkSecretBox(config.ilink.storageKey || readOrCreatePrivateKey(config.ilink.storageKeyFile, 'iLink storage key'))
134
+ : undefined;
135
+ const ilinkStore = config.ilink.enabled
136
+ ? activePersistence.createIlinkStore()
137
+ : undefined;
138
+ const recoveredIlinkReservations = ilinkStore?.recoverPendingAttempts() || 0;
139
+ if (recoveredIlinkReservations) {
140
+ logger.info?.(`[recovery] released pending iLink sends=${recoveredIlinkReservations}`);
141
+ }
142
+ const ilinkMedia = ilinkStore && ilinkSecretBox
143
+ ? new IlinkMediaGateway({ store: ilinkStore, secretBox: ilinkSecretBox })
144
+ : undefined;
145
+ const ilinkTools = ilinkStore && ilinkSecretBox
146
+ ? new IlinkSendExecutor({
147
+ store,
148
+ ilinkStore,
149
+ secretBox: ilinkSecretBox,
150
+ createClient: ({ token, baseUrl }) => new IlinkClient({
151
+ token, baseUrl, timeoutMs: config.ilink.apiTimeoutMs,
152
+ }),
153
+ ...(ilinkMedia ? { mediaGateway: ilinkMedia } : {}),
154
+ })
155
+ : undefined;
156
+ if (ilinkStore && ilinkSecretBox) {
157
+ ilinkOffers = activePersistence.createIlinkLoginStore({
158
+ secretBox: ilinkSecretBox,
159
+ });
160
+ ilinkOffers.cleanup();
161
+ ilinkLogin = new IlinkLoginManager({
162
+ offers: ilinkOffers,
163
+ accounts: ilinkStore,
164
+ secretBox: ilinkSecretBox,
165
+ maxAccounts: config.ilink.maxAccounts,
166
+ logger,
167
+ client: new IlinkClient({
168
+ baseUrl: config.ilink.baseUrl,
169
+ timeoutMs: config.ilink.apiTimeoutMs,
170
+ longPollTimeoutMs: config.ilink.longPollTimeoutMs,
171
+ }),
172
+ onAccountsChanged: () => ilinkListener?.refresh(),
173
+ });
174
+ }
175
+ const channelDispatcher = {
176
+ async kick(channel) {
177
+ if (channel === 'wechat_kf')
178
+ return wechatTools?.kick();
179
+ if (channel === 'weixin_ilink')
180
+ return;
181
+ await wechatTools?.kick();
182
+ },
183
+ async notifyQueued(record) {
184
+ if (record.channel === 'weixin_ilink' && ilinkTools) {
185
+ await ilinkTools.notifyQueued(record.messageKey);
186
+ }
187
+ },
188
+ };
189
+ let conversationMemory;
190
+ const runtimeFile = fileURLToPath(import.meta.url);
191
+ const activeMcpHost = new McpIpcHost({
192
+ instanceKey: config.state.lockFile,
193
+ stateDirectory: path.dirname(config.state.lockFile),
194
+ relayFile: path.resolve(path.dirname(runtimeFile), '..', `mcp-relay${path.extname(runtimeFile)}`),
195
+ ...(wechatTools ? {
196
+ wechatKf: () => createWechatKfMcpServer({
197
+ execute(name, input) {
198
+ if (toolsUnavailable)
199
+ throw new Error('service unavailable');
200
+ return wechatTools.execute(name, input);
201
+ },
202
+ }),
203
+ } : {}),
204
+ memory: () => createConversationMemoryMcpServer({
205
+ read(session) {
206
+ if (toolsUnavailable || !conversationMemory) {
207
+ throw new Error('service unavailable');
208
+ }
209
+ return conversationMemory.read(session);
210
+ },
211
+ }),
212
+ ...(ilinkTools ? {
213
+ ilink: () => createIlinkMcpServer({
214
+ execute(name, input) {
215
+ if (toolsUnavailable)
216
+ throw new Error('service unavailable');
217
+ return ilinkTools.execute(name, input);
218
+ },
219
+ }),
220
+ } : {}),
221
+ logger,
222
+ });
223
+ mcpHost = activeMcpHost;
224
+ const mcpLaunches = await activeMcpHost.start();
225
+ const codex = createCodexAppServer({
226
+ logger,
227
+ mcpLaunches,
228
+ mcpToolTimeoutSec: Math.ceil((config.wecom.api.timeoutMs * 4 +
229
+ config.wecom.api.observeMs +
230
+ 5_000) / 1_000),
231
+ ilinkMcpToolTimeoutSec: Math.ceil((DEFAULT_ILINK_IMAGE_TIMEOUT_MS +
232
+ DEFAULT_ILINK_MEDIA_TIMEOUT_MS +
233
+ config.ilink.apiTimeoutMs +
234
+ 5_000) / 1_000),
235
+ });
236
+ const codexAgent = new CodexAgent({
237
+ codex,
238
+ config: config.codex,
239
+ });
240
+ conversationMemory = new ConversationMemoryExecutor({
241
+ store,
242
+ threads: codex,
243
+ });
244
+ const processor = new ConversationProcessor({
245
+ store,
246
+ agent: codexAgent,
247
+ mediaGateway: {
248
+ resolveForCodex(message) {
249
+ if (message.conversation.channel === 'weixin_ilink' && ilinkMedia) {
250
+ return ilinkMedia.resolveForCodex(message);
251
+ }
252
+ return mediaGateway?.resolveForCodex(message) || Promise.resolve([]);
253
+ },
254
+ },
255
+ channel: channelDispatcher,
256
+ allowedUserIds: config.wecom.allowedUserIds,
257
+ authorization: config.wecom.authorization,
258
+ logger,
259
+ });
260
+ let requestDeferredDrain = () => { };
261
+ const sync = apiClient
262
+ ? new WecomSync({
263
+ apiClient,
264
+ store,
265
+ processor,
266
+ logger,
267
+ startPaused: true,
268
+ onDeferredReady() {
269
+ queueMicrotask(requestDeferredDrain);
270
+ },
271
+ })
272
+ : undefined;
273
+ ilinkListener = ilinkStore && ilinkSecretBox
274
+ ? new IlinkListenerManager({
275
+ logger,
276
+ host: {
277
+ listActiveRuntimeAccounts() {
278
+ const accounts = ilinkStore.listActiveAccountsWithSecrets();
279
+ if (accounts.length > config.ilink.maxAccounts) {
280
+ throw new Error('Active iLink account count exceeds configured limit');
281
+ }
282
+ return accounts.map(({ account, secret }) => ({
283
+ accountKey: account.accountKey,
284
+ providerAccountId: account.providerAccountId,
285
+ ownerPeerId: account.ownerPeerId,
286
+ generation: account.generation,
287
+ cursor: ilinkStore.getCursor(account.accountKey)?.cursor || '',
288
+ botToken: ilinkSecretBox.open(secret.sealedBotToken, {
289
+ secretKind: 'bot_token',
290
+ accountId: account.accountKey,
291
+ peerId: account.ownerPeerId,
292
+ generation: account.generation,
293
+ }),
294
+ baseUrl: account.baseUrl,
295
+ }));
296
+ },
297
+ commitPage(input) {
298
+ const committed = ilinkStore.commitPollPage({
299
+ accountKey: input.accountKey,
300
+ expectedGeneration: input.expectedGeneration,
301
+ expectedCursor: input.expectedCursor,
302
+ nextCursor: input.nextCursor,
303
+ deferredBefore: input.deferredBefore,
304
+ messages: input.messages.map(({ message, facts }) => {
305
+ const { accountKey, peerId } = message.conversation;
306
+ const secretGeneration = ilinkSecretGeneration(message.providerMessageId);
307
+ return {
308
+ message,
309
+ ...(facts.providerSeq === undefined
310
+ ? {}
311
+ : { providerSeq: facts.providerSeq }),
312
+ secretGeneration,
313
+ sealedContextToken: ilinkSecretBox.seal(facts.contextToken, {
314
+ secretKind: 'context_token',
315
+ accountId: accountKey,
316
+ peerId,
317
+ generation: secretGeneration,
318
+ }),
319
+ sealedImages: facts.images.map((image) => {
320
+ const imageGeneration = ilinkSecretGeneration(`${message.providerMessageId}:image:${image.position}`);
321
+ return {
322
+ position: image.position,
323
+ secretGeneration: imageGeneration,
324
+ sealedLocator: ilinkSecretBox.seal(JSON.stringify({
325
+ downloadUrl: image.downloadUrl,
326
+ aesKey: image.aesKey,
327
+ }), {
328
+ secretKind: 'media_locator',
329
+ accountId: accountKey,
330
+ peerId,
331
+ generation: imageGeneration,
332
+ }),
333
+ };
334
+ }),
335
+ };
336
+ }),
337
+ });
338
+ return committed;
339
+ },
340
+ backlogReady() {
341
+ queueMicrotask(requestDeferredDrain);
342
+ },
343
+ enqueue(messageKeys) {
344
+ for (const key of messageKeys)
345
+ void processor.enqueue(key);
346
+ },
347
+ },
348
+ createClient: (account) => new IlinkClient({
349
+ token: account.botToken,
350
+ baseUrl: account.baseUrl,
351
+ timeoutMs: config.ilink.apiTimeoutMs,
352
+ longPollTimeoutMs: config.ilink.longPollTimeoutMs,
353
+ }),
354
+ })
355
+ : undefined;
356
+ let starting;
357
+ let startupRecovery;
358
+ let closing;
359
+ let accepting = true;
360
+ let ilinkClosing;
361
+ let deferredDrain;
362
+ let deferredDrainRequested = false;
363
+ const drainDeferred = async () => {
364
+ while (!closing) {
365
+ await sync?.waitForIdle();
366
+ await processor.waitForIdle();
367
+ const records = activeStore.activateNextDeferredConversation(enabledChannels);
368
+ if (!records.length)
369
+ return;
370
+ await processor.recover(records, { priority: 'low' });
371
+ await processor.waitForIdle();
372
+ }
373
+ };
374
+ requestDeferredDrain = () => {
375
+ if (closing)
376
+ return;
377
+ deferredDrainRequested = true;
378
+ if (deferredDrain)
379
+ return;
380
+ deferredDrain = (async () => {
381
+ do {
382
+ deferredDrainRequested = false;
383
+ await drainDeferred();
384
+ } while (deferredDrainRequested && !closing);
385
+ })().catch((error) => {
386
+ logger.error(`[recovery] deferred backlog failed: ${error instanceof Error ? error.message : String(error)}`);
387
+ }).finally(() => {
388
+ deferredDrain = undefined;
389
+ if (deferredDrainRequested && !closing)
390
+ queueMicrotask(requestDeferredDrain);
391
+ });
392
+ };
393
+ const runtime = {
394
+ messageProcessor: sync || null,
395
+ start() {
396
+ if (!accepting)
397
+ return Promise.reject(new Error('Kintio runtime is stopping'));
398
+ starting ||= (async () => {
399
+ const catchUp = sync?.catchUp() || Promise.resolve();
400
+ const recovery = processor.recover(startupInbound, { priority: 'low' });
401
+ sync?.startConsuming();
402
+ await ilinkListener?.start();
403
+ await ilinkLogin?.start();
404
+ startupRecovery = Promise.all([catchUp, recovery])
405
+ .then(async () => {
406
+ await channelDispatcher.kick();
407
+ if (!ilinkListener)
408
+ requestDeferredDrain();
409
+ })
410
+ .catch((error) => {
411
+ logger.error(`[recovery] startup backlog failed: ${error instanceof Error ? error.message : String(error)}`);
412
+ });
413
+ })();
414
+ return starting;
415
+ },
416
+ stopAccepting() {
417
+ if (!accepting)
418
+ return;
419
+ accepting = false;
420
+ sync?.stopAccepting();
421
+ processor.stopAccepting();
422
+ ilinkClosing ||= Promise.all([
423
+ ilinkLogin?.close(),
424
+ ilinkListener?.close(),
425
+ ]).then(() => undefined);
426
+ },
427
+ close() {
428
+ if (closing)
429
+ return closing;
430
+ runtime.stopAccepting();
431
+ closing = (async () => {
432
+ try {
433
+ await starting?.catch(() => undefined);
434
+ await startupRecovery?.catch(() => undefined);
435
+ await deferredDrain?.catch(() => undefined);
436
+ await sync?.close();
437
+ await ilinkClosing;
438
+ await processor.close();
439
+ }
440
+ finally {
441
+ toolsUnavailable = true;
442
+ await Promise.allSettled([
443
+ ilinkTools?.waitForIdle(),
444
+ wechatTools?.close(),
445
+ ]);
446
+ try {
447
+ await activeMcpHost.close();
448
+ }
449
+ finally {
450
+ if (cleanupTimer)
451
+ clearInterval(cleanupTimer);
452
+ try {
453
+ activeStore.cleanup();
454
+ ilinkOffers?.cleanup();
455
+ activeStore.checkpoint('TRUNCATE');
456
+ }
457
+ finally {
458
+ try {
459
+ activePersistence.close();
460
+ }
461
+ finally {
462
+ if (activePersistence.closed)
463
+ instanceLock.release();
464
+ }
465
+ }
466
+ }
467
+ }
468
+ })();
469
+ return closing;
470
+ },
471
+ async abort() {
472
+ runtime.stopAccepting();
473
+ toolsUnavailable = true;
474
+ wechatTools?.abort();
475
+ await Promise.all([
476
+ processor.abort(),
477
+ ilinkClosing,
478
+ activeMcpHost.close(true),
479
+ ]);
480
+ },
481
+ };
482
+ return runtime;
483
+ }
484
+ catch (error) {
485
+ if (cleanupTimer)
486
+ clearInterval(cleanupTimer);
487
+ await mcpHost?.close(true).catch(() => undefined);
488
+ let persistenceClosed = persistence === undefined &&
489
+ !(error instanceof StatePersistenceUnclosedError);
490
+ try {
491
+ persistence?.close();
492
+ persistenceClosed = true;
493
+ }
494
+ catch (cleanupError) {
495
+ logger.error(`[runtime] startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
496
+ }
497
+ finally {
498
+ if (persistence?.closed || persistenceClosed)
499
+ instanceLock.release();
500
+ }
501
+ throw error;
502
+ }
503
+ }