@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99

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 (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +86 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,553 @@
1
+ import { createNativeDirectoryPublisher, nativeConversationId } from "./hellolynk-native-conversations.mjs";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tryAcquireNativeWorkerLease } from "./hellolynk-native-worker-lease.mjs";
6
+ import { readNativeNoticeLocale, nativeNoticeText } from "./hellolynk-native-notice.mjs";
7
+
8
+ const POLL_MS = 5_000;
9
+ const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$/;
10
+ const closedBeforeDispatch = record => record?.outcome === "closed" && record.dispatchState === "reserved"
11
+ && !record.threadId && !record.uncertainThreadId;
12
+
13
+ // One delivery ledger for the native receivers. Host owns body selection and claims;
14
+ // client bridges supply only lifecycle reads and immediate native acceptance.
15
+ export function createNativeInboxRuntime({ adapter, native, adapterId, acquireLease = tryAcquireNativeWorkerLease,
16
+ pollMs = POLL_MS, timer = setInterval, now = Date.now, onError = reportNativeError, readReplyRoute,
17
+ readNoticeLocale = readNativeNoticeLocale } = {}) {
18
+ if (!adapterId || typeof readReplyRoute !== "function") throw new Error("native_options_invalid");
19
+ const originPrefix = `${adapterId}-origin:`, conversationPrefix = `${adapterId}-conv:`;
20
+ const checkScope = value => validateScope(value, adapterId);
21
+ const publishDirectory = createNativeDirectoryPublisher(native);
22
+ let closed = false;
23
+ let interval;
24
+ let lease;
25
+ let flight;
26
+ let directoryFlight;
27
+ let scope;
28
+ let state;
29
+ let statePath;
30
+ let scannedThreads = new Set();
31
+ let readinessFailure;
32
+ const inspectionFailures = new Map();
33
+ let threadSnapshots = new Map();
34
+ const releaseCurrent = async () => {
35
+ const previous = lease;
36
+ lease = null;
37
+ scope = null;
38
+ state = null;
39
+ statePath = null;
40
+ scannedThreads.clear();
41
+ readinessFailure = null;
42
+ inspectionFailures.clear();
43
+ try { await native.releaseScope?.(); } finally { await previous?.release(); }
44
+ };
45
+ const scopeMatches = (a, b) => JSON.stringify(a) === JSON.stringify(b);
46
+ const loadScope = async (nextScope) => {
47
+ await native.ensureTools?.(["create_thread", "send_message_to_thread", "read_thread", "list_archived_threads"]);
48
+ const candidate = await acquireLease(nextScope);
49
+ if (!candidate) return false;
50
+ try {
51
+ if (closed) return false;
52
+ const { hash, filename: nextPath } = nativeTaskStateLocation(nextScope);
53
+ await assertNoReparsePath(nextScope.runtimeRoot);
54
+ const dir = path.join(nextScope.runtimeRoot, "native-tasks");
55
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
56
+ await assertNoReparsePath(dir);
57
+ let nextState;
58
+ try {
59
+ const stat = await fs.lstat(nextPath);
60
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("native_state_path_unsafe");
61
+ nextState = JSON.parse(await fs.readFile(nextPath, "utf8"));
62
+ validateState(nextState, hash);
63
+ } catch (error) {
64
+ if (error.code !== "ENOENT") throw error;
65
+ nextState = { version: 1, scopeHash: hash, conversations: {} };
66
+ }
67
+ if (closed) return false;
68
+ // Carry forward only actual native pointers and the old per-message
69
+ // fences. Old unknowns are never silently retried by this format change.
70
+ nextState.batches ||= {};
71
+ nextState.carriers ||= Object.fromEntries(Object.entries(nextState.conversations)
72
+ .filter(([, record]) => record.threadId).map(([key, record]) => [`${conversationPrefix}${key}`, record.threadId]));
73
+ await native.configureScope?.(nextScope, nextState);
74
+ if (closed) return false;
75
+ scope = nextScope;
76
+ lease = candidate;
77
+ statePath = nextPath;
78
+ state = nextState;
79
+ return true;
80
+ } finally {
81
+ if (lease !== candidate) {
82
+ try { await native.releaseScope?.(); } finally { await candidate.release(); }
83
+ }
84
+ }
85
+ };
86
+ const ensureReady = async () => {
87
+ if (closed || !native) return false;
88
+ const nextScope = checkScope(await adapter.nativeTaskScope());
89
+ if (closed) return false;
90
+ if (scope && !scopeMatches(scope, nextScope)) await releaseCurrent();
91
+ try {
92
+ const ready = lease ? true : await loadScope(nextScope);
93
+ readinessFailure = null;
94
+ return ready;
95
+ } catch (error) {
96
+ readinessFailure = { scope: nextScope, failure: { code: safeCode(error), at: now() } };
97
+ throw error;
98
+ }
99
+ };
100
+ const stillCurrent = async () => {
101
+ if (closed || !lease) return false;
102
+ const current = checkScope(await adapter.nativeTaskScope());
103
+ return !closed && Boolean(lease) && scopeMatches(scope, current);
104
+ };
105
+ const tick = async () => {
106
+ if (closed || !native) return;
107
+ // The same timer services metadata even while native delivery is waiting.
108
+ // No second receiver or additional polling loop is introduced.
109
+ if (flight) { await directoryTick(); return flight; }
110
+ flight = (async () => {
111
+ await native.beforeTick?.();
112
+ if (!await ensureReady()) return;
113
+ // Catalog failures cannot stop the existing inbox receiver.
114
+ await directoryTick();
115
+ await native.beforeReceive?.();
116
+ threadSnapshots = new Map(); scannedThreads = new Set();
117
+ // Scan metadata to the end before choosing the newest whole messages.
118
+ // A transport page is not the size of a model batch.
119
+ const groups = new Map();
120
+ let cursor = 0;
121
+ do {
122
+ const page = await adapter.listPendingMessages({ cursor });
123
+ if (!Array.isArray(page?.messages)) throw new Error("native_pending_invalid");
124
+ if (!await stillCurrent()) { await releaseCurrent(); return; }
125
+ for (const item of page.messages) {
126
+ if (item.wakeEligible !== true) continue;
127
+ const key = validId(item.conversationKey), id = validId(item.localMessageId);
128
+ const oldOutcome = state.conversations[key]?.messages?.[id] || await native.legacyDelivery?.(item, scope);
129
+ // Historical per-message outcomes remain replay fences, not current batch failures.
130
+ if (oldOutcome) continue;
131
+ let target;
132
+ if (item.targetConversationId) {
133
+ if (item.replyToClientMessageId) throw new Error("native_target_conflicts_with_reply");
134
+ target = `${originPrefix}${nativeConversationId(item.targetConversationId)}`;
135
+ } else if (item.replyToClientMessageId) {
136
+ const origin = await readReplyRoute(scope, item);
137
+ if (!origin) continue;
138
+ target = `${originPrefix}${validId(origin.threadId)}`;
139
+ } else target = `${conversationPrefix}${key}`;
140
+ if (!groups.has(target)) groups.set(target, []);
141
+ groups.get(target).push(id);
142
+ }
143
+ if (page.nextCursor == null) break;
144
+ if (!Number.isSafeInteger(page.nextCursor) || page.nextCursor <= cursor) throw new Error("native_pending_cursor_invalid");
145
+ cursor = page.nextCursor;
146
+ } while (!closed);
147
+ if (!await stillCurrent()) { await releaseCurrent(); return; }
148
+ // Only unresolved batches belong in the receiver's hot path. Delivered
149
+ // bodies stay in Host for one-time reads, not in every five-second poll.
150
+ const opened = await adapter.inboxBatch("open", { state: "reserved" });
151
+ if (!Array.isArray(opened?.batches)) throw new Error("native_batches_invalid");
152
+ const batches = new Map(opened.batches.map(batch => [batch.batchId, batch]));
153
+ let pruned = false;
154
+ for (const [id, record] of Object.entries(state.batches)) {
155
+ if (record.outcome === "accepted") {
156
+ await adapter.inboxBatch("confirm", { batchId: id, ...(record.threadId ? { nativeThreadId: record.threadId } : {}) });
157
+ delete state.batches[id]; batches.delete(id); pruned = true;
158
+ } else if (!batches.has(id) && !closedBeforeDispatch(record)) {
159
+ // A model claim may have supplied the lost acceptance proof. If its
160
+ // known carrier was already closed, mark that delivered batch read.
161
+ if (record.outcome === "closed" && (record.threadId || record.uncertainThreadId))
162
+ await adapter.inboxBatch("closed", { batchId: id });
163
+ delete state.batches[id]; pruned = true;
164
+ }
165
+ }
166
+ if (pruned) await persist();
167
+ await native.prepare?.(scope, state, groups, batches);
168
+ if (!await stillCurrent()) { await releaseCurrent(); return; }
169
+ const incompleteTargets = new Set();
170
+ let firstError;
171
+ const attempt = async action => {
172
+ try { await action(); return true; } catch (error) { firstError ||= error; onError({ code: safeCode(error), operation: "dispatch" }); return false; }
173
+ };
174
+ // Observe closure even with no new pending mail, using the existing tick.
175
+ for (const [target, threadId] of Object.entries(state.carriers)) {
176
+ if (!await stillCurrent()) { await releaseCurrent(); return; }
177
+ await attempt(async () => {
178
+ const result = await inspectThread(threadId);
179
+ if (["archived", "deleted"].includes(result?.state)) await carrierClosed(target, threadId);
180
+ });
181
+ }
182
+ for (const [target, ids] of groups) if (!await attempt(async () => {
183
+ if (!await stillCurrent() || !await destination(target)) return;
184
+ let batch = [...batches.values()].find(value => value.target === target && value.state === "reserved" && value.attempts === 0);
185
+ const latestFirst = [...new Set(ids)].reverse();
186
+ for (let offset = 0; offset < latestFirst.length; offset += 1000) {
187
+ if (!await stillCurrent()) return;
188
+ const input = { target, candidates: latestFirst.slice(offset, offset + 1000), ...(batch ? { batchId: batch.batchId } : {}) };
189
+ const next = await adapter.inboxBatch("reserve", input);
190
+ if (next.status === "empty") continue;
191
+ batch = next; batches.set(batch.batchId, batch);
192
+ }
193
+ })) incompleteTargets.add(target);
194
+ for (const batch of batches.values()) {
195
+ if (!await stillCurrent()) { await releaseCurrent(); return; }
196
+ if (batch.state === "reserved" && !incompleteTargets.has(batch.target)) await attempt(() => dispatchBatch(batch));
197
+ }
198
+ // Positive native acknowledgements may be waiting only for Host confirm.
199
+ const remaining = await adapter.inboxBatch("open", { state: "reserved" });
200
+ const unknownCount = remaining.batches.filter(batch => batch.attempts > 0
201
+ && state.batches[batch.batchId]?.outcome !== "accepted"
202
+ && !(state.batches[batch.batchId]?.dispatchState === "reserved"
203
+ && state.batches[batch.batchId]?.attempt === 1)).length;
204
+ for (const id of inspectionFailures.keys()) if (!scannedThreads.has(id)) inspectionFailures.delete(id);
205
+ let changed = state.pendingUnknownCount !== unknownCount;
206
+ state.pendingUnknownCount = unknownCount;
207
+ if (!firstError && !inspectionFailures.size && state.deliveryFailure) { delete state.deliveryFailure; changed = true; }
208
+ if (changed) await persist();
209
+ if (firstError) throw firstError;
210
+ })().catch(async error => {
211
+ if (state && lease && state.deliveryFailure?.code !== safeCode(error)) {
212
+ state.deliveryFailure = { code: safeCode(error), at: now() };
213
+ await persist().catch(() => {});
214
+ }
215
+ throw error;
216
+ });
217
+ try { await flight; } finally { flight = null; }
218
+ };
219
+ async function directoryTick() {
220
+ const directoryScope = scope, directoryLease = lease;
221
+ const directoryStillCurrent = async () => {
222
+ if (!directoryScope || !directoryLease || scope !== directoryScope || lease !== directoryLease) return false;
223
+ const current = await stillCurrent();
224
+ return current && scope === directoryScope && lease === directoryLease;
225
+ };
226
+ if (!await directoryStillCurrent()) return;
227
+ if (directoryFlight) return directoryFlight;
228
+ directoryFlight = publishDirectory(directoryScope, directoryStillCurrent).catch(error => onError({ code: safeCode(error), operation: "directory" }));
229
+ try { await directoryFlight; } finally { directoryFlight = null; }
230
+ }
231
+ const persist = async () => {
232
+ const temporary = `${statePath}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
233
+ try {
234
+ await assertNoReparsePath(path.dirname(statePath));
235
+ const existing = await fs.lstat(statePath).catch((error) => error.code === "ENOENT" ? null : Promise.reject(error));
236
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw new Error("native_state_path_unsafe");
237
+ await fs.writeFile(temporary, JSON.stringify(state), { flag: "wx", mode: 0o600 });
238
+ await fs.rename(temporary, statePath);
239
+ } finally { await fs.unlink(temporary).catch((error) => { if (error.code !== "ENOENT") onError({ code: "native_temp_cleanup_failed", operation: "persist" }); }); }
240
+ };
241
+ const inspectWithBackoff = async (threadId, inspect) => {
242
+ scannedThreads.add(threadId);
243
+ const prior = inspectionFailures.get(threadId);
244
+ if (prior && now() < prior.retryAt) return { state: "unavailable" };
245
+ if (!threadSnapshots.has(threadId)) threadSnapshots.set(threadId, (async () => {
246
+ try {
247
+ const result = await inspect();
248
+ inspectionFailures.delete(threadId);
249
+ return result;
250
+ } catch (error) {
251
+ const attempts = (prior?.attempts || 0) + 1;
252
+ inspectionFailures.set(threadId, { attempts, retryAt: now() + Math.min(300_000, 30_000 * 2 ** Math.min(attempts - 1, 4)) });
253
+ throw error;
254
+ }
255
+ })());
256
+ return threadSnapshots.get(threadId);
257
+ };
258
+ const inspectThread = threadId => inspectWithBackoff(threadId, () => native.inspectThread
259
+ ? native.inspectThread(threadId) : native.call("read_thread", { threadId, turnLimit: 1 }));
260
+ const carrierClosed = async (target, threadId) => {
261
+ const batches = (await adapter.inboxBatch("open", { target, state: "delivered" })).batches;
262
+ for (const batch of batches) {
263
+ // A manually claimed, previously unsent batch never belonged to a later
264
+ // carrier. Keep its small stop record as the existing deduplication fence.
265
+ if (closedBeforeDispatch(state.batches[batch.batchId])) continue;
266
+ // Confirmed batches use the one carrier pointer. An uncertain older
267
+ // attempt may still name a different, already closed carrier.
268
+ const deliveredThread = state.batches[batch.batchId]?.threadId || state.carriers[target];
269
+ if (deliveredThread !== threadId) continue;
270
+ await adapter.inboxBatch("closed", { batchId: batch.batchId });
271
+ delete state.batches[batch.batchId];
272
+ }
273
+ if (state.carriers[target] === threadId) delete state.carriers[target];
274
+ await persist();
275
+ };
276
+ const destination = async target => {
277
+ const original = target.startsWith(originPrefix);
278
+ if (!original && !target.startsWith(conversationPrefix)) throw new Error("native_batch_target_invalid");
279
+ let threadId = original ? target.slice(originPrefix.length) : state.carriers[target];
280
+ if (!original && !threadId) threadId = await native.legacyCarrier?.(target.slice(conversationPrefix.length), scope);
281
+ if (!threadId) return original || native.canCreate === false ? null : { threadId: null, original: false };
282
+ const result = await inspectThread(threadId);
283
+ if (["archived", "deleted"].includes(result?.state)) {
284
+ if (original) return null;
285
+ await carrierClosed(target, threadId);
286
+ return native.canCreate === false ? null : { threadId: null, original: false };
287
+ }
288
+ const canNotify = original ? (native.canNotifyOrigin || canNotifyOrigin)(result)
289
+ : native.canNotifyCarrier ? native.canNotifyCarrier(result) : canNotifyCarrier(result, native.canNotifyOrigin);
290
+ // A host may use a reusable routing key to locate a carrier, while its
291
+ // model tools use a concrete native session ID (OpenClaw). Pin that ID.
292
+ return canNotify ? { threadId: !original && result.nativeThreadId ? validId(result.nativeThreadId) : threadId, original } : null;
293
+ };
294
+ const dispatchBatch = async batch => {
295
+ const id = batch.batchId;
296
+ let record = state.batches[id];
297
+ if (record?.outcome === "accepted") {
298
+ await adapter.inboxBatch("confirm", { batchId: id, nativeThreadId: record.threadId });
299
+ delete state.batches[id]; await persist(); return;
300
+ }
301
+ // A registered but definitely unsent ticket is reusable, including the
302
+ // second ticket. A possibly sent ticket is never reused for native dispatch.
303
+ const registered = record?.dispatchState === "reserved";
304
+ if (record?.outcome === "closed" || (batch.attempts >= 2 && !registered)) return;
305
+ // If a known carrier was closed after an uncertain send, do not redirect
306
+ // that old attempt to a new carrier. New mail still uses destination().
307
+ const uncertainThreadId = registered ? record.uncertainThreadId : record?.threadId;
308
+ if (uncertainThreadId && batch.attempts > 0) {
309
+ const status = await inspectThread(uncertainThreadId);
310
+ if (["archived", "deleted"].includes(status?.state)) {
311
+ record.outcome = "closed"; await persist(); return;
312
+ }
313
+ }
314
+ // Resolve presentation before creating a carrier or consuming a send try.
315
+ const noticeLocale = await readNoticeLocale();
316
+ if (!await stillCurrent()) return;
317
+ const target = await destination(batch.target);
318
+ if (!target || !await stillCurrent()) return;
319
+ if (batch.attempts > 0) {
320
+ // A cheap native round trip proves the connection is usable before the
321
+ // single retry. Reusing the batch id deduplicates the body read, even if
322
+ // Codex cannot deduplicate its native create/send calls.
323
+ await native.call("list_threads", { limit: 1 });
324
+ if (!await stillCurrent()) return;
325
+ }
326
+ if (!registered) record = state.batches[id] = {
327
+ target: batch.target, threadId: target.threadId, outcome: "unknown",
328
+ attemptId: `inattempt_${crypto.randomUUID()}`, attempt: batch.attempts + 1, dispatchState: "reserved",
329
+ uncertainThreadId: record?.threadId ?? null,
330
+ };
331
+ // Persist before POST. An HTTP timeout may leave the original handler live;
332
+ // replay this idempotency ticket instead of spending a new native chance.
333
+ await persist();
334
+ if (!await stillCurrent()) return;
335
+ const attempted = await adapter.inboxBatch("attempt", { batchId: id, attemptId: record.attemptId,
336
+ ...(target.threadId ? { nativeThreadId: target.threadId } : {}) });
337
+ if (!attempted.attempted) return;
338
+ let result, nativeCalled = false;
339
+ try {
340
+ if (!await stillCurrent()) throw new Error("native_scope_changed");
341
+ record.threadId = target.threadId;
342
+ record.dispatchState = "started";
343
+ await persist();
344
+ if (!await stillCurrent()) throw new Error("native_scope_changed");
345
+ const prompt = batchPrompt(batch, noticeLocale);
346
+ // Do not reuse cached idle state for a second batch in the same tick.
347
+ if (target.threadId) threadSnapshots.delete(target.threadId);
348
+ const context = { batchId: id, target: batch.target, original: target.original, stillCurrent, noticeLocale,
349
+ isStopped: () => closed || !lease,
350
+ async rememberThread(threadId) {
351
+ record.threadId = validId(threadId);
352
+ if (!target.original) state.carriers[batch.target] = record.threadId;
353
+ // Keep the created carrier even if its Host owner pin fails before send.
354
+ await persist();
355
+ await adapter.inboxBatch("attempt", { batchId: id, attemptId: record.attemptId, nativeThreadId: record.threadId });
356
+ } };
357
+ nativeCalled = true;
358
+ result = target.threadId
359
+ ? await native.call("send_message_to_thread", { threadId: target.threadId, hostId: "local", prompt }, context)
360
+ : await native.call("create_thread", { target: { type: "projectless", directoryName: "HelloLynk" }, title: nativeNoticeText(noticeLocale, "independentTitle"), prompt }, context);
361
+ } catch (error) {
362
+ // No native write occurred. Reuse only this registered ticket; keeping
363
+ // the batch and its earlier possibly sent ticket preserves uncertainty.
364
+ if (!nativeCalled || error.nativeDispatchStarted === false) {
365
+ record.dispatchState = "reserved";
366
+ // A confirmed closed prebuilt carrier cannot accept this unsent batch.
367
+ // Stop only this batch; no Host confirm/read action occurred.
368
+ if (error.nativeDispatchStarted === false && error.nativeCarrierClosedBeforeDispatch === true) record.outcome = "closed";
369
+ await persist();
370
+ }
371
+ throw error;
372
+ }
373
+ const accepted = target.threadId ? acceptedSend(result) : validIdOrNull(result?.threadId);
374
+ if (!accepted) return;
375
+ record.threadId = target.threadId || result.threadId; record.outcome = "accepted";
376
+ if (!target.original) state.carriers[batch.target] = record.threadId;
377
+ // Save the positive native result before Host confirmation. A lost Host
378
+ // response retries only confirm, never the accepted model notification.
379
+ await persist();
380
+ await adapter.inboxBatch("confirm", { batchId: id, nativeThreadId: record.threadId });
381
+ delete state.batches[id]; await persist();
382
+ };
383
+ return Object.freeze({
384
+ async start() {
385
+ if (closed || !native) return false;
386
+ if (!interval) interval = timer(() => { void tick().catch((error) => onError({ code: safeCode(error), operation: "poll" })); }, pollMs);
387
+ interval.unref?.();
388
+ void tick().catch((error) => onError({ code: safeCode(error), operation: "start" }));
389
+ return true;
390
+ },
391
+ tick,
392
+ async readDeliveryStatus() {
393
+ // Called only with a model tool result, never from a new polling loop.
394
+ // A different MCP process may own the worker lease; read its same state.
395
+ const current = checkScope(await adapter.nativeTaskScope());
396
+ if (readinessFailure && scopeMatches(readinessFailure.scope, current))
397
+ return { failure: readinessFailure.failure, unknownCount: 0 };
398
+ const { hash, filename } = nativeTaskStateLocation(current);
399
+ let snapshot;
400
+ try { snapshot = JSON.parse(await fs.readFile(filename, "utf8")); }
401
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
402
+ validateState(snapshot, hash);
403
+ if (!scopeMatches(current, checkScope(await adapter.nativeTaskScope()))) return null;
404
+ return {
405
+ failure: snapshot.deliveryFailure ?? null,
406
+ unknownCount: snapshot.pendingUnknownCount ?? 0,
407
+ };
408
+ },
409
+ async close() {
410
+ closed = true;
411
+ if (interval) clearInterval(interval);
412
+ await flight?.catch(() => {});
413
+ await directoryFlight;
414
+ await releaseCurrent();
415
+ await native?.close?.();
416
+ },
417
+ });
418
+ }
419
+
420
+ function nativeTaskStateLocation(scope) {
421
+ const hash = crypto.createHash("sha256").update(JSON.stringify([
422
+ process.platform === "win32" ? path.resolve(scope.runtimeRoot).toLowerCase() : path.resolve(scope.runtimeRoot),
423
+ scope.region, scope.principalId, scope.delegateId, scope.adapterId,
424
+ ])).digest("hex");
425
+ return { hash, filename: path.join(scope.runtimeRoot, "native-tasks", `${hash}.json`) };
426
+ }
427
+
428
+ function validId(value) {
429
+ if (typeof value !== "string" || !ID.test(value)) throw new Error("native_message_id_invalid");
430
+ return value;
431
+ }
432
+ function validIdOrNull(value) { return typeof value === "string" && ID.test(value); }
433
+ function validateScope(value, adapterId) {
434
+ if (!value || typeof value !== "object" || typeof value.runtimeRoot !== "string" || !path.isAbsolute(value.runtimeRoot))
435
+ throw new Error("native_scope_invalid");
436
+ for (const key of ["region", "principalId", "delegateId", "adapterId"])
437
+ if (typeof value[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,240}$/.test(value[key]))
438
+ throw new Error("native_scope_invalid");
439
+ if (value.adapterId !== adapterId) throw new Error("native_scope_adapter_invalid");
440
+ return Object.freeze(Object.fromEntries(["runtimeRoot", "region", "principalId", "delegateId", "adapterId"]
441
+ .map((key) => [key, value[key]])));
442
+ }
443
+ function statusType(value) {
444
+ const status = value?.thread?.status;
445
+ return status == null ? null : typeof status === "string" ? status : status.type;
446
+ }
447
+ function isIdle(value) {
448
+ if (value?.state === "unavailable") return false;
449
+ const status = statusType(value);
450
+ if (value?.thread?.status != null) return status === "idle";
451
+ if (!Array.isArray(value?.turns) || value.turns.length === 0) return false;
452
+ const latest = value.turns[0];
453
+ return latest?.status === "completed" && Boolean(latest.completedAt);
454
+ }
455
+ // The sending session is waiting for this reply, and Codex's own send steers a
456
+ // running turn, so a busy origin is notified now. An unloaded one still waits.
457
+ function canNotifyOrigin(value) {
458
+ if (value?.state === "unavailable") return false;
459
+ if (isIdle(value)) return true;
460
+ const status = statusType(value);
461
+ if (value?.thread?.status != null) return status === "active";
462
+ return value?.turns?.[0]?.status === "inProgress";
463
+ }
464
+ // Use the bridge's established notification capability for independent chats
465
+ // too. Codex can steer an active turn; bridges that require idle keep that
466
+ // restriction. An unloaded carrier may be resumed after a host restart.
467
+ function canNotifyCarrier(value, notifyOrigin = canNotifyOrigin) {
468
+ if (value?.state === "unavailable") return false;
469
+ return notifyOrigin(value) || statusType(value) === "notLoaded";
470
+ }
471
+ function acceptedSend(value) { return value?._nativeToolSuccess === true || value?.threadId || value?.ok === true || value?.status === "sent"; }
472
+ function validateState(value, hash) {
473
+ if (value?.version !== 1 || value.scopeHash !== hash || !value.conversations
474
+ || typeof value.conversations !== "object" || Array.isArray(value.conversations))
475
+ throw new Error("native_state_invalid");
476
+ for (const key of ["batches", "carriers"]) {
477
+ if (value[key] != null && (typeof value[key] !== "object" || Array.isArray(value[key])))
478
+ throw new Error("native_state_invalid");
479
+ }
480
+ if (value.deliveryFailure != null && (typeof value.deliveryFailure !== "object"
481
+ || !/^[A-Za-z0-9_:-]{1,100}$/.test(value.deliveryFailure.code)
482
+ || !Number.isSafeInteger(value.deliveryFailure.at) || value.deliveryFailure.at < 0))
483
+ throw new Error("native_state_invalid");
484
+ if (value.pendingUnknownCount != null && (!Number.isSafeInteger(value.pendingUnknownCount) || value.pendingUnknownCount < 0))
485
+ throw new Error("native_state_invalid");
486
+ for (const [id, record] of Object.entries(value.batches || {})) {
487
+ validId(id); validId(record.target);
488
+ if (!["unknown", "accepted", "closed"].includes(record.outcome) || (record.threadId !== null && !validIdOrNull(record.threadId)))
489
+ throw new Error("native_state_invalid");
490
+ if (record.attemptId !== undefined || record.attempt !== undefined || record.dispatchState !== undefined) {
491
+ if (!validIdOrNull(record.attemptId) || !Number.isSafeInteger(record.attempt) || record.attempt < 1 || record.attempt > 2
492
+ || !["reserved", "started"].includes(record.dispatchState)
493
+ || (record.uncertainThreadId != null && !validIdOrNull(record.uncertainThreadId))) throw new Error("native_state_invalid");
494
+ }
495
+ }
496
+ for (const [target, threadId] of Object.entries(value.carriers || {})) { validId(target); validId(threadId); }
497
+ for (const [conversationKey, record] of Object.entries(value.conversations)) {
498
+ validId(conversationKey);
499
+ if (!record || typeof record !== "object" || !record.messages || typeof record.messages !== "object"
500
+ || Array.isArray(record.messages) || (record.threadId !== null && !validIdOrNull(record.threadId))
501
+ || ![null, "unknown"].includes(record.createState)) throw new Error("native_state_invalid");
502
+ if (record.stoppedAt != null && (!Number.isSafeInteger(record.stoppedAt) || record.stoppedAt < 0))
503
+ throw new Error("native_state_invalid");
504
+ if (record.createCaller != null && !validIdOrNull(record.createCaller)) throw new Error("native_state_invalid");
505
+ for (const [localMessageId, outcome] of Object.entries(record.messages)) {
506
+ validId(localMessageId);
507
+ if (!["sent", "unknown"].includes(outcome)) throw new Error("native_state_invalid");
508
+ }
509
+ if (record.messageThreads !== undefined) {
510
+ if (!record.messageThreads || typeof record.messageThreads !== "object" || Array.isArray(record.messageThreads)) throw new Error("native_state_invalid");
511
+ for (const [message, thread] of Object.entries(record.messageThreads))
512
+ if (!Object.hasOwn(record.messages, message) || !validIdOrNull(thread)) throw new Error("native_state_invalid");
513
+ }
514
+ }
515
+ }
516
+ async function assertNoReparsePath(target) {
517
+ let cursor = path.resolve(target);
518
+ while (true) {
519
+ const stat = await fs.lstat(cursor);
520
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("native_state_directory_unsafe");
521
+ const parent = path.dirname(cursor);
522
+ if (parent === cursor) return;
523
+ cursor = parent;
524
+ }
525
+ }
526
+ function safeCode(error) {
527
+ return /^[A-Za-z0-9_:-]{1,100}$/.test(error?.code || error?.message || "")
528
+ ? String(error.code || error.message) : "native_worker_failed";
529
+ }
530
+ function reportNativeError(event) {
531
+ process.stderr.write(`${JSON.stringify({ event: "hellolynk_native_task_worker", ...event })}\n`);
532
+ }
533
+ function batchPrompt(batch, locale) {
534
+ return nativeNoticeText(locale, "batch", { source: nativeNoticeText(locale, "source"), count: batch.count, batchId: batch.batchId });
535
+ }
536
+
537
+ // Only a currently dispatched batch can bridge the short interval before Host
538
+ // confirmation saves its native owner. Historical carrier guesses are forbidden.
539
+ export async function readNativeInboxBatchDispatch(scope, batchId) {
540
+ validId(batchId);
541
+ const { hash, filename } = nativeTaskStateLocation(scope);
542
+ let source;
543
+ try { source = await fs.readFile(filename, "utf8"); }
544
+ catch (error) {
545
+ if (error.code === "ENOENT") return null;
546
+ if (["EPERM", "EBUSY"].includes(error.code)) return { pending: true, threadId: null };
547
+ throw error;
548
+ }
549
+ const state = JSON.parse(source);
550
+ validateState(state, hash);
551
+ const record = state.batches?.[batchId];
552
+ return record ? { threadId: record.threadId || null, pending: record.dispatchState === "started" } : null;
553
+ }
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { normalizeWorkbenchLocale, SUPPORTED_WORKBENCH_LOCALES, translateWorkbench } from "../workbench/i18n.mjs";
5
+
6
+ const exec = promisify(execFile);
7
+ const commandOptions = { encoding: "utf8", windowsHide: true, timeout: 2000, maxBuffer: 8192 };
8
+ const localeTag = value => typeof value === "string" && /^[A-Za-z]{2,8}(?:[-_][A-Za-z0-9]{2,8})*(?:\.[A-Za-z0-9_-]+)?(?:@[A-Za-z0-9_-]+)?$/.test(value.trim())
9
+ ? value.trim().split(/[.@]/, 1)[0] : null;
10
+
11
+ // Lazy and single-flight: no subprocess on idle receiver ticks, and at most
12
+ // one display-language lookup per process. No saved setting or polling loop.
13
+ export function createNativeNoticeLocaleReader({ platform = process.platform, env = process.env, run = exec } = {}) {
14
+ let pending;
15
+ async function read() {
16
+ let languages;
17
+ try {
18
+ if (platform === "win32") {
19
+ const executable = path.win32.join(env.SystemRoot || "C:\\Windows", "System32", "reg.exe");
20
+ // Read display-language tags directly: a console's UI culture can be
21
+ // filtered by its code page, and Intl reports the formatting region.
22
+ const deadline = Date.now() + commandOptions.timeout;
23
+ for (const [key, name] of [["HKCU\\Control Panel\\Desktop", "PreferredUILanguages"],
24
+ ["HKCU\\Control Panel\\Desktop\\MuiCached", "MachinePreferredUILanguages"]]) {
25
+ const timeout = deadline - Date.now();
26
+ if (timeout <= 0) break;
27
+ const result = await run(executable, ["query", key, "/v", name], { ...commandOptions, timeout }).catch(() => ({ stdout: "" }));
28
+ languages = /REG_MULTI_SZ[ \t]+([^\r\n]*)/.exec(result.stdout)?.[1].split("\\0").map(localeTag);
29
+ if (languages?.some(Boolean)) break;
30
+ }
31
+ } else if (platform === "darwin") {
32
+ const result = await run("/usr/bin/defaults", ["read", "-g", "AppleLanguages"], commandOptions);
33
+ // defaults prints an array of language tags, in preferred order.
34
+ languages = result.stdout.trim().replace(/^\(\s*|\s*\)$/g, "").split(",")
35
+ .map(value => localeTag(value.trim().replace(/^"|"$/g, "")));
36
+ } else {
37
+ const messages = env.LC_ALL || env.LC_MESSAGES || env.LANG;
38
+ if (!messages || /^(?:C|POSIX)(?:\.|$)/i.test(messages)) return "en";
39
+ languages = (env.LANGUAGE || messages || "").split(":").map(localeTag);
40
+ }
41
+ } catch { /* A locale lookup must never prevent receipt of a message. */ }
42
+ if (languages?.some(Boolean)) return normalizeWorkbenchLocale(languages);
43
+ return "en";
44
+ }
45
+ return () => pending ||= read();
46
+ }
47
+
48
+ export const readNativeNoticeLocale = createNativeNoticeLocaleReader();
49
+ export const nativeNoticeText = (locale, key, vars) => translateWorkbench(locale, `nativeNotice.${key}`, vars);
50
+ export const NATIVE_NOTICE_PREFIXES = Object.freeze(SUPPORTED_WORKBENCH_LOCALES.map(locale => nativeNoticeText(locale, "source")));