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

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 +101 -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 +277 -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,232 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { redeemAgentBindingKey, runSanitizedDoctor } from "./hellolynk-client-core.mjs";
6
+ import { createPlatformCredentialStore } from "./hellolynk-platform-credential-store.mjs";
7
+ import {
8
+ defaultHelloLynkClientRoot,
9
+ readClientProfile,
10
+ writeClientProfile,
11
+ } from "./hellolynk-client-profile.mjs";
12
+ import {
13
+ createNeutralHostInstallationStore,
14
+ writeNeutralHostProfileManifest,
15
+ } from "./hellolynk-neutral-host-installation-store.mjs";
16
+ import { createHostProtectedStore } from "./hellolynk-host-protected-store.mjs";
17
+ import { createDeviceEnrollmentClient } from "./hellolynk-device-enrollment.mjs";
18
+
19
+ function parseArgs(argv) {
20
+ const [command, ...rest] = argv;
21
+ const options = {};
22
+ for (let index = 0; index < rest.length; index += 1) {
23
+ const part = rest[index];
24
+ if ([
25
+ "--allow-loopback-http", "--binding-key-from-stdin", "--bootstrap-host-transport",
26
+ ].includes(part)) {
27
+ options[part.slice(2)] = true;
28
+ continue;
29
+ }
30
+ if (!part.startsWith("--") || index + 1 >= rest.length) throw new Error("client_arguments_invalid");
31
+ options[part.slice(2)] = rest[index + 1];
32
+ index += 1;
33
+ }
34
+ return { command, options };
35
+ }
36
+
37
+ function required(options, key) {
38
+ const value = String(options[key] || "").trim();
39
+ if (!value) throw new Error(`client_argument_${key}_required`);
40
+ return value;
41
+ }
42
+
43
+ async function readBindingKeyFromStdin() {
44
+ const chunks = [];
45
+ let bytes = 0;
46
+ for await (const input of process.stdin) {
47
+ const chunk = Buffer.from(input);
48
+ bytes += chunk.length;
49
+ if (bytes > 4 * 1024) throw new Error("binding_key_input_too_large");
50
+ chunks.push(chunk);
51
+ const value = Buffer.concat(chunks);
52
+ const lineEnd = value.indexOf(0x0a);
53
+ if (lineEnd >= 0) {
54
+ const line = value.subarray(0, lineEnd).toString("utf8").replace(/\r$/, "");
55
+ if (!line) throw new Error("binding_key_stdin_required");
56
+ return line;
57
+ }
58
+ }
59
+ const value = Buffer.concat(chunks).toString("utf8").replace(/\r$/, "");
60
+ if (!value) throw new Error("binding_key_stdin_required");
61
+ return value;
62
+ }
63
+
64
+ function publicErrorCode(error) {
65
+ const code = String(error?.code || error?.message || "client_operation_failed");
66
+ return /^[a-z][a-z0-9_]{0,127}$/.test(code) ? code : "client_operation_failed";
67
+ }
68
+
69
+ async function bind(options, dependencies = {}) {
70
+ if (options["binding-key-from-stdin"] !== true) throw new Error("binding_key_stdin_flag_required");
71
+ const root = options.root || defaultHelloLynkClientRoot();
72
+ const profileId = required(options, "profile");
73
+ const configPath = options.config || join(root, "profiles", `${profileId}.json`);
74
+ const bindingKey = await (dependencies.readSecret || readBindingKeyFromStdin)();
75
+ const store = dependencies.store || createPlatformCredentialStore({ storeRoot: join(root, "credentials") });
76
+ const redeemed = await redeemAgentBindingKey({
77
+ baseUrl: required(options, "base-url"),
78
+ bindingKey,
79
+ deviceId: required(options, "device-id"),
80
+ hostInstallationId: required(options, "host-installation-id"),
81
+ bootstrapHostTransport: options["bootstrap-host-transport"] === true,
82
+ allowLoopbackHttp: options["allow-loopback-http"] === true,
83
+ fetchImpl: dependencies.fetchImpl,
84
+ });
85
+ await store.save(profileId, redeemed.credential);
86
+ try {
87
+ const profile = await writeClientProfile(configPath, {
88
+ contract: "hellolynk-client-profile-v1",
89
+ schemaVersion: redeemed.hostId && redeemed.authorizationVersion ? 2 : 1,
90
+ profileId,
91
+ baseUrl: options["base-url"],
92
+ allowLoopbackHttp: options["allow-loopback-http"] === true,
93
+ deviceId: redeemed.deviceId,
94
+ credentialId: redeemed.credentialId,
95
+ principalId: redeemed.principalId,
96
+ hellolynkId: redeemed.hellolynkId,
97
+ agentId: redeemed.agentId,
98
+ delegateId: redeemed.delegateId,
99
+ ...(redeemed.hostId && redeemed.authorizationVersion ? {
100
+ hostId: redeemed.hostId,
101
+ authorizationVersion: redeemed.authorizationVersion,
102
+ } : {}),
103
+ region: redeemed.region,
104
+ allowedScopes: redeemed.allowedScopes,
105
+ platform: process.platform,
106
+ });
107
+ const neutralHostRoot = options["neutral-host-root"] == null
108
+ ? null : resolve(required(options, "neutral-host-root"));
109
+ let neutralHostActivated = false;
110
+ let bootstrapTransportStored = false;
111
+ if (neutralHostRoot) {
112
+ if (options.adapter == null) throw new Error("client_argument_adapter_required");
113
+ const installationStore = dependencies.installationStore
114
+ || createNeutralHostInstallationStore({ root: neutralHostRoot });
115
+ const installation = await installationStore.adoptProfileFile({
116
+ profileFile: configPath,
117
+ adapterId: required(options, "adapter"),
118
+ });
119
+ await writeActivationProfileManifest({
120
+ clientRoot: root,
121
+ configPath,
122
+ neutralHostRoot,
123
+ installation,
124
+ });
125
+ if (redeemed.bootstrapHostTransportCredential) {
126
+ const protectedStore = dependencies.protectedStore || createHostProtectedStore({
127
+ root: neutralHostRoot,
128
+ installationStore,
129
+ });
130
+ await protectedStore.saveTransportCredential({
131
+ credentialId: redeemed.bootstrapHostTransportCredential.credentialId,
132
+ principalId: redeemed.principalId,
133
+ hostInstallationId: redeemed.hostId,
134
+ state: "active",
135
+ credential: redeemed.bootstrapHostTransportCredential.credential,
136
+ createdAt: redeemed.bootstrapHostTransportCredential.createdAt,
137
+ expiresAt: redeemed.bootstrapHostTransportCredential.expiresAt,
138
+ revokedAt: null,
139
+ });
140
+ bootstrapTransportStored = true;
141
+ }
142
+ neutralHostActivated = true;
143
+ }
144
+ return {
145
+ ok: true,
146
+ profileId: profile.profileId,
147
+ credentialStored: true,
148
+ configPath,
149
+ neutralHostActivated,
150
+ bootstrapTransportStored,
151
+ };
152
+ } catch (error) {
153
+ await store.remove(profileId).catch(() => {});
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ async function writeActivationProfileManifest(input) {
159
+ const manifestPath = join(input.neutralHostRoot, "profile-manifest-v1.json");
160
+ const knownPaths = new Map([[basename(input.configPath, ".json"), input.configPath]]);
161
+ try {
162
+ const existing = JSON.parse(await readFile(manifestPath, "utf8"));
163
+ if (existing?.contract === "hellolynk-neutral-host-profile-manifest-v1" && Array.isArray(existing.profiles)) {
164
+ for (const candidate of existing.profiles) {
165
+ const absolute = resolve(String(candidate || ""));
166
+ knownPaths.set(basename(absolute, ".json"), absolute);
167
+ }
168
+ }
169
+ } catch {}
170
+ const profileIds = input.installation.delegateBindings.map((item) => item.profileId);
171
+ const orderedIds = [
172
+ input.installation.canonicalProfileId,
173
+ ...profileIds.filter((profileId) => profileId !== input.installation.canonicalProfileId).sort(),
174
+ ];
175
+ const profiles = orderedIds.map((profileId) => (
176
+ knownPaths.get(profileId) || join(input.clientRoot, "profiles", `${profileId}.json`)
177
+ ));
178
+ await writeNeutralHostProfileManifest({ root: input.neutralHostRoot, profiles });
179
+ }
180
+
181
+ async function doctor(options, dependencies = {}) {
182
+ const root = options.root || defaultHelloLynkClientRoot();
183
+ const profileId = required(options, "profile");
184
+ const configPath = options.config || join(root, "profiles", `${profileId}.json`);
185
+ const profile = await readClientProfile(configPath);
186
+ if (profile.profileId !== profileId) throw new Error("client_profile_binding_mismatch");
187
+ const store = dependencies.store || createPlatformCredentialStore({ storeRoot: join(root, "credentials") });
188
+ let credentialPresent = false;
189
+ try {
190
+ let credential = await store.load(profileId);
191
+ credentialPresent = Boolean(credential);
192
+ credential = null;
193
+ } catch {
194
+ credentialPresent = false;
195
+ }
196
+ return runSanitizedDoctor({
197
+ baseUrl: profile.baseUrl,
198
+ allowLoopbackHttp: profile.allowLoopbackHttp,
199
+ credentialPresent,
200
+ platform: profile.platform,
201
+ fetchImpl: dependencies.fetchImpl,
202
+ });
203
+ }
204
+
205
+ export async function runRelayClientCli(argv = process.argv.slice(2), dependencies = {}) {
206
+ const { command, options } = parseArgs(argv);
207
+ if (command === "connect") {
208
+ const allowed = new Set(["base-url", "root", "neutral-host-root", "allow-loopback-http", "hellolynk-id", "adapter", "profile", "instance-name", "device-label"]);
209
+ if (Object.keys(options).some((key) => !allowed.has(key))) throw new Error("device_enrollment_argument_unknown");
210
+ return createDeviceEnrollmentClient({
211
+ baseUrl: required(options, "base-url"), root: options.root, hostRoot: options["neutral-host-root"],
212
+ allowLoopbackHttp: options["allow-loopback-http"] === true,
213
+ }, dependencies).connect({
214
+ hellolynkId: required(options, "hellolynk-id"), clientType: required(options, "adapter"),
215
+ profileId: options.profile, requestedName: options["instance-name"], deviceLabel: options["device-label"],
216
+ });
217
+ }
218
+ if (command === "bind") return bind(options, dependencies);
219
+ if (command === "doctor") return doctor(options, dependencies);
220
+ throw new Error("client_command_invalid");
221
+ }
222
+
223
+ const directRun = process.argv[1] && basename(process.argv[1]) === basename(fileURLToPath(import.meta.url));
224
+ if (directRun) {
225
+ runRelayClientCli().then(
226
+ (result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`),
227
+ (error) => {
228
+ process.stderr.write(`${JSON.stringify({ ok: false, error: publicErrorCode(error) })}\n`);
229
+ process.exitCode = 1;
230
+ },
231
+ );
232
+ }
@@ -0,0 +1,277 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execute = promisify(execFile);
8
+ const clients = ['codex', 'openclaw', 'trae-work', 'workbuddy', 'dsh', 'claude-code'];
9
+ const contract = 'hellolynk-release-retention-v1';
10
+ // Claude starts every HelloLynk process through one stable copy of the entry, so
11
+ // a command line never names the release that process loaded. The entry records
12
+ // it under releases/.claude-code-processes/<pid>.json (see stable-entry.mjs).
13
+ // Cleanup code from before these records reads only a v1 receipt and stops at
14
+ // any other one before deleting, so a client with a stable entry keeps a v2
15
+ // receipt: an older worker that cleans up after an update deletes nothing.
16
+ const ENTRY = { 'claude-code': ['claude-code', 'hellolynk-claude-code.mjs'] };
17
+ const contractWithEntry = 'hellolynk-release-retention-v2';
18
+ const PROCESS_CONTRACT = 'hellolynk-release-process-v1';
19
+ const START_TOLERANCE_MS = 15000;
20
+ export const releaseProcessDirectory = (releasesRoot, client) => path.join(releasesRoot, `.${client}-processes`);
21
+ const failure = code => Object.assign(new Error(code), { code });
22
+ const equal = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
23
+
24
+ async function directory(target) {
25
+ const stat = await fs.lstat(target);
26
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure('release_retention_path_invalid');
27
+ return stat;
28
+ }
29
+
30
+ async function rootPath(root) {
31
+ if (!path.isAbsolute(root || '') || path.basename(root) !== 'releases') throw failure('release_retention_root_invalid');
32
+ const resolved = path.resolve(root);
33
+ for (let p = resolved; ; p = path.dirname(p)) {
34
+ await directory(p);
35
+ if (p === path.dirname(p)) break;
36
+ }
37
+ // Windows may supply an 8.3 TEMP/user path. Parents were checked above;
38
+ // delete only under its canonical absolute spelling.
39
+ return fs.realpath(resolved);
40
+ }
41
+
42
+ async function regularTree(target) {
43
+ await directory(target);
44
+ for (const item of await fs.readdir(target, { withFileTypes: true })) {
45
+ const child = path.join(target, item.name), stat = await fs.lstat(child);
46
+ if (stat.isSymbolicLink()) throw failure('release_retention_link');
47
+ if (stat.isDirectory()) await regularTree(child);
48
+ else if (!stat.isFile() || stat.nlink !== 1) throw failure('release_retention_file_invalid');
49
+ }
50
+ }
51
+
52
+ // Read only release references, never return process command lines. A running
53
+ // older Host may still import files after installation; deletion waits for the
54
+ // next successful install instead of stopping any user process.
55
+ export async function runningReleaseNames(releasesRoot) {
56
+ if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
57
+ const root = await rootPath(releasesRoot);
58
+ const literal = `${root}${path.sep}`.replaceAll("'", "''");
59
+ const script = `$ErrorActionPreference='Stop'; $pattern=[regex]::Escape('${literal}')+'((?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64})(?=[\\\\/"\\s]|$)'; $names=@(Get-CimInstance Win32_Process | ForEach-Object { if ($_.CommandLine) { foreach ($m in [regex]::Matches($_.CommandLine,$pattern,'IgnoreCase')) { $m.Groups[1].Value.ToLowerInvariant() } } }); ConvertTo-Json -Compress -InputObject @($names | Sort-Object -Unique)`;
60
+ const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
61
+ const result = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
62
+ { windowsHide: true, timeout: 15000, maxBuffer: 256 * 1024 });
63
+ const names = JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
64
+ if (!Array.isArray(names) || names.some(n => typeof n !== 'string' || !/^(?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64}$/.test(n))) {
65
+ throw failure('release_retention_process_check_invalid');
66
+ }
67
+ return names;
68
+ }
69
+
70
+ // The given pids and every process whose command line runs this installation's
71
+ // stable entry: pid, start time and whether it runs the entry. Only processes
72
+ // with a positive pid are listed, and command lines never leave PowerShell.
73
+ export async function entryProcessList(client, releasesRoot, pids = []) {
74
+ if (!ENTRY[client]) return [];
75
+ if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
76
+ if (!Array.isArray(pids) || pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw failure('release_retention_process_check_invalid');
77
+ const root = await rootPath(releasesRoot);
78
+ // This installation's entry only, in its given and canonical spellings:
79
+ // any case, either slash, quoted or not.
80
+ const literal = value => value.split(/[\\/]+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\\\/]+');
81
+ const entries = [...new Set([root, path.resolve(releasesRoot)])].map(value => literal(path.join(path.dirname(value), ...ENTRY[client])));
82
+ const pattern = `(?<=^|["\\s])(?:${entries.join('|')})(?=["\\s]|$)`.replaceAll("'", "''");
83
+ const script = `$ErrorActionPreference='Stop'; $wanted=@(${pids.join(',')}); $epoch=[datetime]::new(1970,1,1,0,0,0,[DateTimeKind]::Utc); $list=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -gt 0 } | ForEach-Object { $entry=[bool]($_.CommandLine -and [regex]::IsMatch($_.CommandLine,'${pattern}','IgnoreCase')); if ($entry -or ($wanted -contains [int64]$_.ProcessId)) { [pscustomobject]@{ pid=[int64]$_.ProcessId; startedAt=$(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - $epoch).TotalMilliseconds) } else { $null }); entry=$entry } } }); ConvertTo-Json -Compress -InputObject $list`;
84
+ const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
85
+ const { stdout } = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
86
+ { windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
87
+ const list = JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || '[]');
88
+ if (!Array.isArray(list) || list.some(p => !Number.isSafeInteger(p?.pid) || p.pid <= 0 || typeof p.entry !== 'boolean'
89
+ || (p.startedAt !== null && !Number.isSafeInteger(p.startedAt)))) throw failure('release_retention_process_check_invalid');
90
+ return list;
91
+ }
92
+
93
+ // Releases named by live entry records, and whether a process started through
94
+ // the stable entry has no matching record (one from before records existed).
95
+ // Records of exited or reused pids are removed, so a release can go at the
96
+ // next install once its last process is gone.
97
+ async function entryReleases(releasesRoot, root, client, pattern, dependencies) {
98
+ if (!ENTRY[client]) return { names: [], unregistered: false };
99
+ const folder = releaseProcessDirectory(root, client);
100
+ let items = [];
101
+ try {
102
+ await directory(folder);
103
+ items = await fs.readdir(folder, { withFileTypes: true });
104
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
105
+ const records = items.filter(item => item.isFile() && /^[1-9]\d{0,9}\.json$/.test(item.name))
106
+ .map(item => ({ pid: Number(item.name.slice(0, -5)), file: path.join(folder, item.name) }));
107
+ // The given root too: an entry may be named in the spelling the caller uses, not only the canonical one.
108
+ const processes = await (dependencies.entryProcesses || entryProcessList)(client, releasesRoot, records.map(record => record.pid));
109
+ const live = new Map(processes.map(p => [p.pid, p])), names = new Set(), registered = new Set();
110
+ let unregistered = false;
111
+ for (const { pid, file } of records) {
112
+ const alive = live.get(pid), started = Number.isSafeInteger(alive?.startedAt) ? alive.startedAt : null;
113
+ let record = null;
114
+ try { record = JSON.parse(await fs.readFile(file, 'utf8')); } catch {}
115
+ const valid = record?.contract === PROCESS_CONTRACT && record.pid === pid && Number.isSafeInteger(record.startedAt)
116
+ && typeof record.release === 'string' && pattern.test(record.release);
117
+ if (valid && started !== null && Math.abs(started - record.startedAt) <= START_TOLERANCE_MS) {
118
+ registered.add(pid); names.add(record.release);
119
+ } else if (!alive || (valid && started !== null)) {
120
+ await fs.rm(file, { force: true }).catch(() => {}); // Exited, or the pid now belongs to a later process.
121
+ } else unregistered = true; // Live, but its record is still being written or its start time is unknown.
122
+ }
123
+ if (processes.some(p => p.entry && !registered.has(p.pid))) unregistered = true;
124
+ return { names: [...names], unregistered };
125
+ }
126
+
127
+ // A stopped on-demand task is still a configuration reference. Keeping only
128
+ // live processes can delete its executable before the next client startup.
129
+ export async function configuredReleaseNames(releasesRoot) {
130
+ if (process.platform !== 'win32') throw failure('release_retention_configuration_check_unavailable');
131
+ const root = await rootPath(releasesRoot);
132
+ const prefixes = [...new Set([root, path.resolve(releasesRoot)])].map(value => (value + path.sep).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
133
+ const pattern = ('(?:' + prefixes.join('|') + ')((?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64})(?=[\\\\/"\\s]|$)').replaceAll("'", "''");
134
+ const script = "$ErrorActionPreference='Stop'; $pattern='" + pattern + "'; $scheduler=New-Object -ComObject 'Schedule.Service'; $scheduler.Connect(); $names=@($scheduler.GetFolder('\\').GetTasks(1) | Where-Object { $_.Name -like 'HelloLynk *' } | ForEach-Object { foreach ($a in $_.Definition.Actions) { foreach ($v in @($a.Path,$a.Arguments,$a.WorkingDirectory)) { foreach ($m in [regex]::Matches([string]$v,$pattern,'IgnoreCase')) { $m.Groups[1].Value.ToLowerInvariant() } } } }); ConvertTo-Json -Compress -InputObject @($names | Sort-Object -Unique)";
135
+ const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
136
+ const { stdout } = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
137
+ { windowsHide: true, timeout: 15000, maxBuffer: 256 * 1024 });
138
+ const names = JSON.parse(stdout.replace(/^\uFEFF/, '').trim());
139
+ if (!Array.isArray(names) || names.some(n => typeof n !== 'string' || !/^(?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64}$/.test(n))) {
140
+ throw failure('release_retention_configuration_check_invalid');
141
+ }
142
+ return names;
143
+ }
144
+
145
+ // Call only after the caller's complete adoption transaction succeeds, while
146
+ // holding its install/account lock. Never call from candidate staging or a
147
+ // rollback. The receipt records adoption order, not directory timestamps.
148
+ // Cleanup failure is reported separately and must not roll back a good install.
149
+ export async function pruneInstalledReleases({ releasesRoot, client, currentRelease, previousRelease = null, referencedReleases = [] }, dependencies = {}) {
150
+ const result = { complete: false, retained: [], removed: [], deferred: [] };
151
+ try {
152
+ if (!clients.includes(client)) throw failure('release_retention_client_invalid');
153
+ const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
154
+ const nameOf = value => {
155
+ if (typeof value !== 'string' || !path.isAbsolute(value)
156
+ || ![root, path.resolve(releasesRoot)].some(p => equal(path.dirname(path.resolve(value)), p))
157
+ || !pattern.test(path.basename(value))) throw failure('release_retention_target_invalid');
158
+ return path.basename(value);
159
+ };
160
+ const current = nameOf(currentRelease);
161
+ const receiptPath = path.join(root, `.${client}-retention.json`);
162
+ const old = await readReceipt(receiptPath, client, pattern);
163
+ const supplied = previousRelease === null ? null : nameOf(previousRelease);
164
+ const previous = supplied && supplied !== current ? supplied
165
+ : old?.current === current ? old.previous : old?.current || null;
166
+ const retained = [current, previous].filter(Boolean);
167
+ for (const name of retained) await directory(path.join(root, name));
168
+ const referenced = new Set([...referencedReleases.map(nameOf),
169
+ ...await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot)]);
170
+ const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
171
+ const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
172
+ // Publish before deleting. An interrupted cleanup can retry the exact same
173
+ // pair on same-version reinstall without accidentally dropping its backup.
174
+ await writeReceipt(receiptPath, { contract: receiptContract(client), client, current, previous });
175
+ result.retained = retained;
176
+ await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies, entry.unregistered);
177
+ result.complete = result.deferred.length === 0;
178
+ } catch (error) { result.error = safeCode(error); }
179
+ return result;
180
+ }
181
+
182
+ // A stable-entry client calls this before it switches an install on its stable
183
+ // entry. An update worker of an earlier release cleans up after that install with
184
+ // its own code, which cannot see what a stable-entry process runs and stops
185
+ // before deleting only at a receipt other than v1. Resolves once the receipt is
186
+ // v2, keeping the adoption it records, with whether it wrote. Throws when that
187
+ // cannot be confirmed, even for a cause that passes before the worker reads the
188
+ // receipt (a file another process holds open): the caller then switches nothing.
189
+ // currentRelease, the release the install starts now, is recorded only when
190
+ // there is no receipt yet.
191
+ export async function protectRetentionReceipt({ releasesRoot, client, currentRelease }) {
192
+ if (!ENTRY[client]) throw failure('release_retention_client_invalid');
193
+ const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
194
+ const receiptPath = path.join(root, `.${client}-retention.json`);
195
+ const old = await readReceipt(receiptPath, client, pattern);
196
+ if (old?.contract === contractWithEntry) return false;
197
+ let current = old?.current;
198
+ if (!current) {
199
+ if (typeof currentRelease !== 'string' || !path.isAbsolute(currentRelease) || !pattern.test(path.basename(currentRelease))
200
+ || ![root, path.resolve(releasesRoot)].some(p => equal(path.dirname(path.resolve(currentRelease)), p))) throw failure('release_retention_target_invalid');
201
+ current = path.basename(currentRelease);
202
+ await directory(path.join(root, current));
203
+ }
204
+ await writeReceipt(receiptPath, { contract: contractWithEntry, client, current, previous: old ? old.previous : null });
205
+ return true;
206
+ }
207
+
208
+ const receiptContract = client => ENTRY[client] ? contractWithEntry : contract;
209
+
210
+ async function readReceipt(receiptPath, client, pattern) {
211
+ let old = null;
212
+ try {
213
+ const stat = await fs.lstat(receiptPath);
214
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 4096) throw failure('release_retention_receipt_invalid');
215
+ old = JSON.parse(await fs.readFile(receiptPath, 'utf8'));
216
+ if (![contract, contractWithEntry].includes(old?.contract) || old.client !== client || !pattern.test(old.current)
217
+ || old.previous !== null && !pattern.test(old.previous) || old.current === old.previous) throw failure('release_retention_receipt_invalid');
218
+ } catch (error) {
219
+ if (error.code === 'ENOENT') return null;
220
+ throw error.code ? error : failure('release_retention_receipt_invalid');
221
+ }
222
+ return old;
223
+ }
224
+
225
+ async function writeReceipt(receiptPath, receipt) {
226
+ const temporary = `${receiptPath}.${crypto.randomUUID()}.tmp`;
227
+ try {
228
+ await fs.writeFile(temporary, JSON.stringify(receipt) + '\n', { flag: 'wx', mode: 0o600 });
229
+ await fs.rename(temporary, receiptPath);
230
+ } finally { await fs.unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
231
+ }
232
+
233
+ // Uninstall keeps no release. One a process or scheduled task still uses is
234
+ // left for the next install or uninstall, like any other deferred release.
235
+ export async function removeInstalledReleases({ releasesRoot, client }, dependencies = {}) {
236
+ const result = { complete: false, removed: [], deferred: [] };
237
+ try {
238
+ if (!clients.includes(client)) throw failure('release_retention_client_invalid');
239
+ try { await fs.lstat(releasesRoot); } catch (error) { if (error.code === 'ENOENT') return { ...result, complete: true }; throw error; }
240
+ const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
241
+ const referenced = new Set(await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot));
242
+ const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
243
+ const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
244
+ await removeReleases(root, name => pattern.test(name), referenced, running, result, dependencies, entry.unregistered);
245
+ if (!result.deferred.length) {
246
+ await fs.rm(path.join(root, `.${client}-retention.json`), { force: true });
247
+ // entryReleases checked above that this is a folder in the releases root.
248
+ if (ENTRY[client]) await fs.rm(releaseProcessDirectory(root, client), { recursive: true, force: true });
249
+ }
250
+ result.complete = result.deferred.length === 0;
251
+ } catch (error) { result.error = safeCode(error); }
252
+ return result;
253
+ }
254
+
255
+ // An unregistered entry process may run any release of its client, so then
256
+ // none is removed until it exits.
257
+ async function removeReleases(root, selected, referenced, running, result, dependencies, unregistered = false) {
258
+ for (const name of await fs.readdir(root)) {
259
+ if (!selected(name)) continue;
260
+ if (referenced.has(name)) { result.deferred.push({ name, reason: 'release_still_referenced' }); continue; }
261
+ if (running.has(name)) { result.deferred.push({ name, reason: 'release_in_use' }); continue; }
262
+ if (unregistered) { result.deferred.push({ name, reason: 'release_in_use_unregistered' }); continue; }
263
+ try {
264
+ // The final absolute target is a checked direct child of the canonical
265
+ // releases root; never follow a junction inside an obsolete package.
266
+ const target = path.resolve(root, name);
267
+ if (!equal(path.dirname(target), root) || !equal(await fs.realpath(target), target)) throw failure('release_retention_path_invalid');
268
+ await regularTree(target);
269
+ await (dependencies.remove || fs.rm)(target, { recursive: true });
270
+ result.removed.push(name);
271
+ } catch (error) { result.deferred.push({ name, reason: safeCode(error) }); }
272
+ }
273
+ }
274
+
275
+ function safeCode(error) {
276
+ return /^(?:release_retention_[a-z_]+|E[A-Z]+)$/.test(error?.code || '') ? error.code : 'release_retention_failed';
277
+ }