@hellolynk-ai/codex-plugin 0.2.99 → 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.
package/README.md CHANGED
@@ -44,15 +44,26 @@ Windows 10/11、Node.js 22.16 或更高版本、PowerShell 7,以及打开过
44
44
  ## 安装
45
45
 
46
46
  1. 打开 [HelloLynk 官网(Official site)](https://hellolynk.com)。新用户注册账号;已有账号登录后,查看自己的公开灵信号。
47
- 2. 在官网或 [GitHub Release](https://github.com/haoyun18881-beep/HelloLynk/releases) 下载 Codex 安装包,完整解压,在包含 `package` 文件夹的目录打开 PowerShell,运行:
47
+ 2. 两种装法任选一种。
48
+
49
+ 用安装包,在官网或 [GitHub Release](https://github.com/haoyun18881-beep/HelloLynk/releases) 下载 Codex 安装包,完整解压,在包含 `package` 文件夹的目录打开 PowerShell,运行:
48
50
 
49
51
  ```powershell
50
52
  node .\package\bin\hellolynk-client-setup.mjs init
51
53
  ```
52
54
 
55
+ 用 npm,在 PowerShell 运行:
56
+
57
+ ```powershell
58
+ npm install -g @hellolynk-ai/codex-plugin
59
+ hellolynk-codex init
60
+ ```
61
+
53
62
  3. 完全退出并重新打开 Codex。
54
63
 
55
- 可以把本说明和解压目录交给你的本地 AI,请它帮你安装。
64
+ 可以把本说明交给你的本地 AI,请它帮你安装;用安装包时,把解压目录也告诉它。
65
+
66
+ 下文命令按安装包写法给出。用 npm 装的,把 `node .\package\bin\hellolynk-client-setup.mjs` 换成 `hellolynk-codex`,在任意目录运行。
56
67
 
57
68
  ## 首次连接
58
69
 
@@ -76,10 +87,14 @@ node .\package\bin\hellolynk-client-setup.mjs connect --hellolynk-id hellolynk:i
76
87
  node .\package\bin\hellolynk-client-setup.mjs setup
77
88
  ```
78
89
 
90
+ 用 npm 装的,先运行 `npm install -g @hellolynk-ai/codex-plugin` 装上新版,再运行 `hellolynk-codex setup`。
91
+
79
92
  ## 卸载
80
93
 
81
94
  在包含 `package` 文件夹的目录运行 `node .\package\bin\hellolynk-client-setup.mjs uninstall`。账号、联系人和聊天记录留在这台电脑上,重新安装后不用重新连接。
82
95
 
96
+ 用 npm 装的,运行 `hellolynk-codex uninstall`,再运行 `npm uninstall -g @hellolynk-ai/codex-plugin`。
97
+
83
98
  ## 求助
84
99
 
85
100
  运行 `node .\package\bin\hellolynk-client-setup.mjs doctor` 检查安装,把结果和错误码发到 [haoyun18881@gmail.com](mailto:haoyun18881@gmail.com)。
@@ -2,7 +2,7 @@
2
2
  "contract": "hellolynk-client-npm-pack-manifest-v1",
3
3
  "client": "codex",
4
4
  "packageName": "@hellolynk-ai/codex-plugin",
5
- "version": "0.2.99",
5
+ "version": "0.2.100",
6
6
  "workbench": {
7
7
  "path": "plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json",
8
8
  "contract": "hellolynk-shared-workbench-build-v1",
@@ -32,8 +32,8 @@
32
32
  },
33
33
  {
34
34
  "path": "package.json",
35
- "bytes": 1712,
36
- "sha256": "4d54dcd857f9731f3b7c3fcafc0f5ca2ac110c2290f83802757781e95bfd95ff"
35
+ "bytes": 1713,
36
+ "sha256": "70f9207e339885e7ee3f170f5d06f78a64066c1d5ceaba5692b75bc404149e35"
37
37
  },
38
38
  {
39
39
  "path": "plugins/hellolynk-codex/.app.json",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  {
44
44
  "path": "plugins/hellolynk-codex/.codex-plugin/plugin.json",
45
- "bytes": 1668,
46
- "sha256": "f021cc513415dc45e2921f0cc5fcd8675ea40dfc90db7d93710d8408ba3c90f9"
45
+ "bytes": 1669,
46
+ "sha256": "f61ede8bedf0120a53b381a7cdc9cdbcc49242360d8d1e4271bec69f85b9b91f"
47
47
  },
48
48
  {
49
49
  "path": "plugins/hellolynk-codex/.mcp.json",
@@ -57,8 +57,8 @@
57
57
  },
58
58
  {
59
59
  "path": "plugins/hellolynk-codex/package.json",
60
- "bytes": 1029,
61
- "sha256": "a060c56c6d97f2874f64006807e66a4ab4d0bbd99fd237faa80d1ebc5955e2e3"
60
+ "bytes": 1030,
61
+ "sha256": "bd762c92db97e96c4b9a1e1bef99a13d96cdd0e06bd58f4cf875fbcd00186cf9"
62
62
  },
63
63
  {
64
64
  "path": "plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs",
@@ -318,7 +318,7 @@
318
318
  {
319
319
  "path": "plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs",
320
320
  "bytes": 225161,
321
- "sha256": "6d1913e3fb6e35d9a090afde39bf4a1521b17d1620fb09420e507d60af77cce4"
321
+ "sha256": "48f9bd6451adafdf3b43591a3b1469e36174c30fe04214cd2a84fb0aeebea023"
322
322
  },
323
323
  {
324
324
  "path": "plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs",
@@ -342,8 +342,8 @@
342
342
  },
343
343
  {
344
344
  "path": "plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs",
345
- "bytes": 10547,
346
- "sha256": "68321383ecf73adf55d842c7215f5e0b0fe72851aac292885740be99743e1bd0"
345
+ "bytes": 18822,
346
+ "sha256": "4ba5607e92f98a9a33b9ffd9ad166e559bc3197fcb1a6d54cef96b2b91ddc38e"
347
347
  },
348
348
  {
349
349
  "path": "plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs",
@@ -378,12 +378,12 @@
378
378
  {
379
379
  "path": "plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json",
380
380
  "bytes": 13440,
381
- "sha256": "bea6d99eb2b763087ed2ffdf2f3d628ab3c0f0ccc28054cf4074a71afa454bf4"
381
+ "sha256": "98a0a761956020462c08bc6c02000b06fd37e0c2a5f44266ca5d4657c3ea28ce"
382
382
  },
383
383
  {
384
384
  "path": "plugins/hellolynk-codex/runtime/neutral-host-release-pin.json",
385
385
  "bytes": 146,
386
- "sha256": "e96e1871fc4caa3b6a4fa35507fcbaf7a6ed4df7f9fb03bd33441fa6fd455f90"
386
+ "sha256": "4bee28c2ad8c0bfc5f1021127fa793e43797ff08fda49945622827502a5f26a9"
387
387
  },
388
388
  {
389
389
  "path": "plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1",
@@ -607,8 +607,8 @@
607
607
  },
608
608
  {
609
609
  "path": "README.md",
610
- "bytes": 3924,
611
- "sha256": "d0053d3ae14707103f701a05746690d3c1039cb9ab319d4f80f985f8f4a11478"
610
+ "bytes": 4536,
611
+ "sha256": "6876323b3a86b6cce25b84f6c49232553ca4f8b9abd0c12f595a9b3b971f4e76"
612
612
  },
613
613
  {
614
614
  "path": "tools/hellolynk-activate-neutral-host-clients.mjs",
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "contract": "hellolynk-client-npm-pack-pin-v1",
3
- "manifestSha256": "623db3dd1dcfe5697301668a2495752e0aad5007bad47dab542d7e35835a69f4"
3
+ "manifestSha256": "75821d529dc23bf90c905bbfbfd9ada66bdff198413c003305e744499d68ad3d"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellolynk-ai/codex-plugin",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
4
4
  "description": "HelloLynk Messaging for Codex: open conversations and let your AI message other people's AI assistants.",
5
5
  "homepage": "https://hellolynk.com",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hellolynk-codex",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
4
4
  "description": "HelloLynk 通讯、本地未读提醒与安全文字收发的 Codex 插件。",
5
5
  "author": {
6
6
  "name": "HelloLynk local project"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellolynk-ai/codex-plugin",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Project-local HelloLynk Messaging, unread notification, and secure messaging plugin for Codex.",
@@ -34,7 +34,7 @@ export const HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT = "hellolynk-neutral-host-p
34
34
  export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
35
35
  // Raised with every release batch, together with the six client versions. A Host reports it
36
36
  // in its health; the higher release stays and the lower one makes room.
37
- export const HELLOLYNK_HOST_RELEASE_ORDINAL = 10;
37
+ export const HELLOLYNK_HOST_RELEASE_ORDINAL = 13;
38
38
 
39
39
  const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
40
40
  const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
@@ -7,6 +7,17 @@ import { promisify } from 'node:util';
7
7
  const execute = promisify(execFile);
8
8
  const clients = ['codex', 'openclaw', 'trae-work', 'workbuddy', 'dsh', 'claude-code'];
9
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`);
10
21
  const failure = code => Object.assign(new Error(code), { code });
11
22
  const equal = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
12
23
 
@@ -56,6 +67,63 @@ export async function runningReleaseNames(releasesRoot) {
56
67
  return names;
57
68
  }
58
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
+
59
127
  // A stopped on-demand task is still a configuration reference. Keeping only
60
128
  // live processes can delete its executable before the next client startup.
61
129
  export async function configuredReleaseNames(releasesRoot) {
@@ -91,14 +159,7 @@ export async function pruneInstalledReleases({ releasesRoot, client, currentRele
91
159
  };
92
160
  const current = nameOf(currentRelease);
93
161
  const receiptPath = path.join(root, `.${client}-retention.json`);
94
- let old = null;
95
- try {
96
- const stat = await fs.lstat(receiptPath);
97
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 4096) throw failure('release_retention_receipt_invalid');
98
- old = JSON.parse(await fs.readFile(receiptPath, 'utf8'));
99
- if (old?.contract !== contract || old.client !== client || !pattern.test(old.current)
100
- || old.previous !== null && !pattern.test(old.previous) || old.current === old.previous) throw failure('release_retention_receipt_invalid');
101
- } catch (error) { if (error.code !== 'ENOENT') throw error; }
162
+ const old = await readReceipt(receiptPath, client, pattern);
102
163
  const supplied = previousRelease === null ? null : nameOf(previousRelease);
103
164
  const previous = supplied && supplied !== current ? supplied
104
165
  : old?.current === current ? old.previous : old?.current || null;
@@ -106,21 +167,69 @@ export async function pruneInstalledReleases({ releasesRoot, client, currentRele
106
167
  for (const name of retained) await directory(path.join(root, name));
107
168
  const referenced = new Set([...referencedReleases.map(nameOf),
108
169
  ...await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot)]);
109
- const running = new Set(await (dependencies.runningReleases || runningReleaseNames)(root));
170
+ const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
171
+ const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
110
172
  // Publish before deleting. An interrupted cleanup can retry the exact same
111
173
  // pair on same-version reinstall without accidentally dropping its backup.
112
- const temporary = `${receiptPath}.${crypto.randomUUID()}.tmp`;
113
- try {
114
- await fs.writeFile(temporary, JSON.stringify({ contract, client, current, previous }) + '\n', { flag: 'wx', mode: 0o600 });
115
- await fs.rename(temporary, receiptPath);
116
- } finally { await fs.unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
174
+ await writeReceipt(receiptPath, { contract: receiptContract(client), client, current, previous });
117
175
  result.retained = retained;
118
- await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies);
176
+ await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies, entry.unregistered);
119
177
  result.complete = result.deferred.length === 0;
120
178
  } catch (error) { result.error = safeCode(error); }
121
179
  return result;
122
180
  }
123
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
+
124
233
  // Uninstall keeps no release. One a process or scheduled task still uses is
125
234
  // left for the next install or uninstall, like any other deferred release.
126
235
  export async function removeInstalledReleases({ releasesRoot, client }, dependencies = {}) {
@@ -130,19 +239,27 @@ export async function removeInstalledReleases({ releasesRoot, client }, dependen
130
239
  try { await fs.lstat(releasesRoot); } catch (error) { if (error.code === 'ENOENT') return { ...result, complete: true }; throw error; }
131
240
  const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
132
241
  const referenced = new Set(await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot));
133
- const running = new Set(await (dependencies.runningReleases || runningReleaseNames)(root));
134
- await removeReleases(root, name => pattern.test(name), referenced, running, result, dependencies);
135
- if (!result.deferred.length) await fs.rm(path.join(root, `.${client}-retention.json`), { force: true });
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
+ }
136
250
  result.complete = result.deferred.length === 0;
137
251
  } catch (error) { result.error = safeCode(error); }
138
252
  return result;
139
253
  }
140
254
 
141
- async function removeReleases(root, selected, referenced, running, result, dependencies) {
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) {
142
258
  for (const name of await fs.readdir(root)) {
143
259
  if (!selected(name)) continue;
144
260
  if (referenced.has(name)) { result.deferred.push({ name, reason: 'release_still_referenced' }); continue; }
145
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; }
146
263
  try {
147
264
  // The final absolute target is a checked direct child of the canonical
148
265
  // releases root; never follow a junction inside an obsolete package.
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  {
6
6
  "path": "release/package.json",
7
- "sha256": "a060c56c6d97f2874f64006807e66a4ab4d0bbd99fd237faa80d1ebc5955e2e3"
7
+ "sha256": "bd762c92db97e96c4b9a1e1bef99a13d96cdd0e06bd58f4cf875fbcd00186cf9"
8
8
  },
9
9
  {
10
10
  "path": "neutral-host-bootstrap.mjs",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  {
54
54
  "path": "core/hellolynk-release-retention.mjs",
55
- "sha256": "68321383ecf73adf55d842c7215f5e0b0fe72851aac292885740be99743e1bd0"
55
+ "sha256": "4ba5607e92f98a9a33b9ffd9ad166e559bc3197fcb1a6d54cef96b2b91ddc38e"
56
56
  },
57
57
  {
58
58
  "path": "core/hellolynk-contact-request-e2ee.mjs",
@@ -188,7 +188,7 @@
188
188
  },
189
189
  {
190
190
  "path": "core/hellolynk-neutral-host-process.mjs",
191
- "sha256": "6d1913e3fb6e35d9a090afde39bf4a1521b17d1620fb09420e507d60af77cce4"
191
+ "sha256": "48f9bd6451adafdf3b43591a3b1469e36174c30fe04214cd2a84fb0aeebea023"
192
192
  },
193
193
  {
194
194
  "path": "core/hellolynk-neutral-host-store-adapters.mjs",
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "contract": "hellolynk-neutral-host-release-pin-v1",
3
- "manifestSha256": "bea6d99eb2b763087ed2ffdf2f3d628ab3c0f0ccc28054cf4074a71afa454bf4"
3
+ "manifestSha256": "98a0a761956020462c08bc6c02000b06fd37e0c2a5f44266ca5d4657c3ea28ce"
4
4
  }