@hellolynk-ai/codex-plugin 0.2.99 → 0.2.101
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 +17 -2
- package/bin/hellolynk-client-setup.mjs +7 -0
- package/hellolynk-client-pack-manifest.json +20 -20
- package/hellolynk-client-pack-pin.json +1 -1
- package/package.json +1 -1
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/hellolynk-codex/package.json +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +157 -19
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +5 -5
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +2 -0
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.
|
|
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
|
-
|
|
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)。
|
|
@@ -84,6 +84,13 @@ try {
|
|
|
84
84
|
if (mutating) {
|
|
85
85
|
await prepareFinalClientRoot(locations);
|
|
86
86
|
installed = await installStablePackage({ packageClosure, sourceRelease, locations: packageLocations });
|
|
87
|
+
if (metadata.client === "codex") {
|
|
88
|
+
// The invoking updater can still be B255 code. Stop its old cleanup
|
|
89
|
+
// before switching the binding/cache to a release with process records.
|
|
90
|
+
const cleanup = await importStableCore(installed.packageRoot, "hellolynk-release-retention.mjs", "hellolynk_release_retention_unavailable");
|
|
91
|
+
await cleanup.protectRetentionReceipt({ releasesRoot: path.dirname(installed.packageRoot),
|
|
92
|
+
client: "codex", currentRelease: installed.packageRoot });
|
|
93
|
+
}
|
|
87
94
|
}
|
|
88
95
|
const activation = await inspectActivation(locations.hostRoot, metadata.profileId);
|
|
89
96
|
// Both public install commands use the actual account state. Native recovery
|
|
@@ -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.
|
|
5
|
+
"version": "0.2.101",
|
|
6
6
|
"workbench": {
|
|
7
7
|
"path": "plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json",
|
|
8
8
|
"contract": "hellolynk-shared-workbench-build-v1",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "bin/hellolynk-client-setup.mjs",
|
|
20
|
-
"bytes":
|
|
21
|
-
"sha256": "
|
|
20
|
+
"bytes": 141769,
|
|
21
|
+
"sha256": "0bb0706c06bd193cfa37007dd95e3107b52aacb0a7ebaec2249b153ff7dd772b"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"path": "bin/hellolynk-mcp-config-shape.mjs",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"path": "package.json",
|
|
35
|
-
"bytes":
|
|
36
|
-
"sha256": "
|
|
35
|
+
"bytes": 1713,
|
|
36
|
+
"sha256": "3cd83c41e8536877505bf043a77b15fed01a0a7be18978c9835898fb690dfc8e"
|
|
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":
|
|
46
|
-
"sha256": "
|
|
45
|
+
"bytes": 1669,
|
|
46
|
+
"sha256": "dfe2ede67b747c94a7485ecc6150286e3ac73f0fd1cae93357f473feae9ae5e3"
|
|
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":
|
|
61
|
-
"sha256": "
|
|
60
|
+
"bytes": 1030,
|
|
61
|
+
"sha256": "5875df8d4163c0a84b3dc30265b1a5e7b977a7c92250aae013d3b513b3640c40"
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"path": "plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs",
|
|
@@ -267,8 +267,8 @@
|
|
|
267
267
|
},
|
|
268
268
|
{
|
|
269
269
|
"path": "plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs",
|
|
270
|
-
"bytes":
|
|
271
|
-
"sha256": "
|
|
270
|
+
"bytes": 19443,
|
|
271
|
+
"sha256": "f4bd81b3d03277e28955a62409383a0c4072c6212567f71425df4e18ece75d63"
|
|
272
272
|
},
|
|
273
273
|
{
|
|
274
274
|
"path": "plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.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": "
|
|
321
|
+
"sha256": "af6cb6585baae477f87ee880bd044dffb4c7f71667bfa78bcbf0d4d20e7483af"
|
|
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":
|
|
346
|
-
"sha256": "
|
|
345
|
+
"bytes": 20624,
|
|
346
|
+
"sha256": "9b02a42a510fa5ef4a251b6e675f9ccc60f20b718d17c1617027cd048c5634dd"
|
|
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": "
|
|
381
|
+
"sha256": "5b035cb2d82e7543b801ca9d3b35a36e3f54a7b9aff9afcdf9f479926c537f56"
|
|
382
382
|
},
|
|
383
383
|
{
|
|
384
384
|
"path": "plugins/hellolynk-codex/runtime/neutral-host-release-pin.json",
|
|
385
385
|
"bytes": 146,
|
|
386
|
-
"sha256": "
|
|
386
|
+
"sha256": "af12ef2162ae5cddfda9707d919e4c7008478da434c581bcc26c8ed01738be11"
|
|
387
387
|
},
|
|
388
388
|
{
|
|
389
389
|
"path": "plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1",
|
|
@@ -552,8 +552,8 @@
|
|
|
552
552
|
},
|
|
553
553
|
{
|
|
554
554
|
"path": "plugins/hellolynk-codex/server/codex-neutral-production.mjs",
|
|
555
|
-
"bytes":
|
|
556
|
-
"sha256": "
|
|
555
|
+
"bytes": 26960,
|
|
556
|
+
"sha256": "ffb8e689df1b600bcca85159af8d59f0fad819827f48ba9a218a63dfd7a3daa2"
|
|
557
557
|
},
|
|
558
558
|
{
|
|
559
559
|
"path": "plugins/hellolynk-codex/server/mcp-proxy.mjs",
|
|
@@ -607,8 +607,8 @@
|
|
|
607
607
|
},
|
|
608
608
|
{
|
|
609
609
|
"path": "README.md",
|
|
610
|
-
"bytes":
|
|
611
|
-
"sha256": "
|
|
610
|
+
"bytes": 4536,
|
|
611
|
+
"sha256": "6876323b3a86b6cce25b84f6c49232553ca4f8b9abd0c12f595a9b3b971f4e76"
|
|
612
612
|
},
|
|
613
613
|
{
|
|
614
614
|
"path": "tools/hellolynk-activate-neutral-host-clients.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellolynk-ai/codex-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.101",
|
|
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": {
|
|
@@ -18,7 +18,7 @@ const conversationGuidance = "HelloLynk 是本人授权的 AI 通讯。接着已
|
|
|
18
18
|
// What every client shows for the shared tools; nativeTool applies it.
|
|
19
19
|
const toolText = Object.freeze({
|
|
20
20
|
hellolynk_reply_authorized_message: { title: "回复来信", description: "接着当前对话的一封来信发送;不接手其他对话。" },
|
|
21
|
-
hellolynk_send_authorized_message: { title: "发起通信", description: "向本人授权的联系人发起新通信。contact 填对方名字;本人点名了对话时,targetConversationTitle
|
|
21
|
+
hellolynk_send_authorized_message: { title: "发起通信", description: "向本人授权的联系人发起新通信。contact 填对方名字;本人点名了对话时,targetConversationTitle 填完整标题。未指定对话的新通信默认送到对方的 HelloLynk 独立会话;要继续已有来往,用 hellolynk_reply_authorized_message 回复原信。" },
|
|
22
22
|
hellolynk_list_authorized_contacts: { title: "查找联系人", description: queryDescription },
|
|
23
23
|
hellolynk_claim_message: { title: "读取来信", description: "按自动提醒里的 batchId 一次读取整批来信(合计不超过 2 万字);手动读单条传 localMessageId 和 conversationKey。" },
|
|
24
24
|
hellolynk_list_pending_messages: { title: "待处理来信", description: "列出当前对话等待 AI 处理的来信,不含正文。" },
|
|
@@ -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 =
|
|
37
|
+
export const HELLOLYNK_HOST_RELEASE_ORDINAL = 14;
|
|
38
38
|
|
|
39
39
|
const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
|
|
40
40
|
const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
|
|
@@ -7,9 +7,37 @@ 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
|
+
// B255 workers understand v2 but do not read Codex process records.
|
|
19
|
+
const codexContract = 'hellolynk-release-retention-v3';
|
|
20
|
+
const PROCESS_CONTRACT = 'hellolynk-release-process-v1';
|
|
21
|
+
const START_TOLERANCE_MS = 15000;
|
|
22
|
+
export const releaseProcessDirectory = (releasesRoot, client) => path.join(releasesRoot, `.${client}-processes`);
|
|
10
23
|
const failure = code => Object.assign(new Error(code), { code });
|
|
11
24
|
const equal = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
12
25
|
|
|
26
|
+
// A cache MCP keeps an installed prepare function in memory. Record its most
|
|
27
|
+
// recent successful binding, even when its command line names only the cache.
|
|
28
|
+
// A checkout/uninstalled fixture has no immutable release to retain.
|
|
29
|
+
export async function recordCodexReleaseProcess(packageRoot) {
|
|
30
|
+
if (process.platform !== 'win32' || !path.isAbsolute(packageRoot || '')
|
|
31
|
+
|| path.basename(path.dirname(packageRoot)) !== 'releases' || !/^codex-[a-f0-9]{64}$/.test(path.basename(packageRoot))) return;
|
|
32
|
+
try {
|
|
33
|
+
const root = await rootPath(path.dirname(packageRoot));
|
|
34
|
+
const folder = releaseProcessDirectory(root, 'codex');
|
|
35
|
+
await fs.mkdir(folder, { recursive: true });
|
|
36
|
+
await writeReceipt(path.join(folder, `${process.pid}.json`), { contract: PROCESS_CONTRACT, pid: process.pid,
|
|
37
|
+
startedAt: Math.round(performance.timeOrigin), release: path.basename(packageRoot) });
|
|
38
|
+
} catch { /* A missing record defers cleanup; it must not stop communication. */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
async function directory(target) {
|
|
14
42
|
const stat = await fs.lstat(target);
|
|
15
43
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure('release_retention_path_invalid');
|
|
@@ -56,6 +84,68 @@ export async function runningReleaseNames(releasesRoot) {
|
|
|
56
84
|
return names;
|
|
57
85
|
}
|
|
58
86
|
|
|
87
|
+
// The given pids, this installation's stable entry, and Codex cache children:
|
|
88
|
+
// pid, start time and whether it runs the entry. Only processes
|
|
89
|
+
// with a positive pid are listed, and command lines never leave PowerShell.
|
|
90
|
+
export async function entryProcessList(client, releasesRoot, pids = []) {
|
|
91
|
+
if (!ENTRY[client] && client !== 'codex') return [];
|
|
92
|
+
if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
|
|
93
|
+
if (!Array.isArray(pids) || pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw failure('release_retention_process_check_invalid');
|
|
94
|
+
const root = await rootPath(releasesRoot);
|
|
95
|
+
// This installation's entry only, in its given and canonical spellings:
|
|
96
|
+
// any case, either slash, quoted or not.
|
|
97
|
+
const literal = value => value.split(/[\\/]+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\\\/]+');
|
|
98
|
+
const entries = ENTRY[client] ? [...new Set([root, path.resolve(releasesRoot)])].map(value => literal(path.join(path.dirname(value), ...ENTRY[client]))) : [];
|
|
99
|
+
const pattern = (client === 'codex' ? '(?:^|["\\s\\\\/])(?:\\.[\\\\/])?server[\\\\/]mcp-proxy\\.mjs(?=["\\s]|$)'
|
|
100
|
+
: `(?<=^|["\\s])(?:${entries.join('|')})(?=["\\s]|$)`).replaceAll("'", "''");
|
|
101
|
+
// Relative cache entries do not reveal their installation. An older Codex
|
|
102
|
+
// child without a record conservatively delays this client's cleanup until
|
|
103
|
+
// it reconnects through a recording release or exits. Other Node apps do not.
|
|
104
|
+
const parentCheck = client === 'codex' ? "$_.Name -ieq 'node.exe' -and ($codexParents -contains $_.ParentProcessId) -and " : '';
|
|
105
|
+
const script = `$ErrorActionPreference='Stop'; $wanted=@(${pids.join(',')}); $epoch=[datetime]::new(1970,1,1,0,0,0,[DateTimeKind]::Utc); $all=@(Get-CimInstance Win32_Process); $codexParents=@($all | Where-Object { $_.Name -ieq 'Codex.exe' } | ForEach-Object { $_.ProcessId }); $list=@($all | Where-Object { $_.ProcessId -gt 0 } | ForEach-Object { $entry=[bool](${parentCheck}$_.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`;
|
|
106
|
+
const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
107
|
+
const { stdout } = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
108
|
+
{ windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
109
|
+
const list = JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || '[]');
|
|
110
|
+
if (!Array.isArray(list) || list.some(p => !Number.isSafeInteger(p?.pid) || p.pid <= 0 || typeof p.entry !== 'boolean'
|
|
111
|
+
|| (p.startedAt !== null && !Number.isSafeInteger(p.startedAt)))) throw failure('release_retention_process_check_invalid');
|
|
112
|
+
return list;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Releases named by live entry records, and whether a process started through
|
|
116
|
+
// the stable entry has no matching record (one from before records existed).
|
|
117
|
+
// Records of exited or reused pids are removed, so a release can go at the
|
|
118
|
+
// next install once its last process is gone.
|
|
119
|
+
async function entryReleases(releasesRoot, root, client, pattern, dependencies) {
|
|
120
|
+
if (!ENTRY[client] && client !== 'codex') return { names: [], unregistered: false };
|
|
121
|
+
const folder = releaseProcessDirectory(root, client);
|
|
122
|
+
let items = [];
|
|
123
|
+
try {
|
|
124
|
+
await directory(folder);
|
|
125
|
+
items = await fs.readdir(folder, { withFileTypes: true });
|
|
126
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
127
|
+
const records = items.filter(item => item.isFile() && /^[1-9]\d{0,9}\.json$/.test(item.name))
|
|
128
|
+
.map(item => ({ pid: Number(item.name.slice(0, -5)), file: path.join(folder, item.name) }));
|
|
129
|
+
// The given root too: an entry may be named in the spelling the caller uses, not only the canonical one.
|
|
130
|
+
const processes = await (dependencies.entryProcesses || entryProcessList)(client, releasesRoot, records.map(record => record.pid));
|
|
131
|
+
const live = new Map(processes.map(p => [p.pid, p])), names = new Set(), registered = new Set();
|
|
132
|
+
let unregistered = false;
|
|
133
|
+
for (const { pid, file } of records) {
|
|
134
|
+
const alive = live.get(pid), started = Number.isSafeInteger(alive?.startedAt) ? alive.startedAt : null;
|
|
135
|
+
let record = null;
|
|
136
|
+
try { record = JSON.parse(await fs.readFile(file, 'utf8')); } catch {}
|
|
137
|
+
const valid = record?.contract === PROCESS_CONTRACT && record.pid === pid && Number.isSafeInteger(record.startedAt)
|
|
138
|
+
&& typeof record.release === 'string' && pattern.test(record.release);
|
|
139
|
+
if (valid && started !== null && Math.abs(started - record.startedAt) <= START_TOLERANCE_MS) {
|
|
140
|
+
registered.add(pid); names.add(record.release);
|
|
141
|
+
} else if (!alive || (valid && started !== null)) {
|
|
142
|
+
await fs.rm(file, { force: true }).catch(() => {}); // Exited, or the pid now belongs to a later process.
|
|
143
|
+
} else unregistered = true; // Live, but its record is still being written or its start time is unknown.
|
|
144
|
+
}
|
|
145
|
+
if (processes.some(p => p.entry && !registered.has(p.pid))) unregistered = true;
|
|
146
|
+
return { names: [...names], unregistered };
|
|
147
|
+
}
|
|
148
|
+
|
|
59
149
|
// A stopped on-demand task is still a configuration reference. Keeping only
|
|
60
150
|
// live processes can delete its executable before the next client startup.
|
|
61
151
|
export async function configuredReleaseNames(releasesRoot) {
|
|
@@ -91,14 +181,7 @@ export async function pruneInstalledReleases({ releasesRoot, client, currentRele
|
|
|
91
181
|
};
|
|
92
182
|
const current = nameOf(currentRelease);
|
|
93
183
|
const receiptPath = path.join(root, `.${client}-retention.json`);
|
|
94
|
-
|
|
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; }
|
|
184
|
+
const old = await readReceipt(receiptPath, client, pattern);
|
|
102
185
|
const supplied = previousRelease === null ? null : nameOf(previousRelease);
|
|
103
186
|
const previous = supplied && supplied !== current ? supplied
|
|
104
187
|
: old?.current === current ? old.previous : old?.current || null;
|
|
@@ -106,21 +189,68 @@ export async function pruneInstalledReleases({ releasesRoot, client, currentRele
|
|
|
106
189
|
for (const name of retained) await directory(path.join(root, name));
|
|
107
190
|
const referenced = new Set([...referencedReleases.map(nameOf),
|
|
108
191
|
...await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot)]);
|
|
109
|
-
const
|
|
192
|
+
const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
|
|
193
|
+
const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
|
|
110
194
|
// Publish before deleting. An interrupted cleanup can retry the exact same
|
|
111
195
|
// pair on same-version reinstall without accidentally dropping its backup.
|
|
112
|
-
|
|
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; }); }
|
|
196
|
+
await writeReceipt(receiptPath, { contract: receiptContract(client), client, current, previous });
|
|
117
197
|
result.retained = retained;
|
|
118
|
-
await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies);
|
|
198
|
+
await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies, entry.unregistered);
|
|
119
199
|
result.complete = result.deferred.length === 0;
|
|
120
200
|
} catch (error) { result.error = safeCode(error); }
|
|
121
201
|
return result;
|
|
122
202
|
}
|
|
123
203
|
|
|
204
|
+
// Call before switching the binding/entry. An earlier updater cleans up after
|
|
205
|
+
// setup with its own code. Claude needs v2 to stop pre-B255 workers; Codex needs
|
|
206
|
+
// v3 to stop B255 workers that know only Claude records. Preserve the recorded
|
|
207
|
+
// adoption order and resolve with whether the receipt changed. Throw when that
|
|
208
|
+
// cannot be confirmed, even for a cause that passes before the worker reads the
|
|
209
|
+
// receipt (a file another process holds open): the caller then switches nothing.
|
|
210
|
+
// currentRelease, the release the install starts now, is recorded only when
|
|
211
|
+
// there is no receipt yet.
|
|
212
|
+
export async function protectRetentionReceipt({ releasesRoot, client, currentRelease }) {
|
|
213
|
+
if (!ENTRY[client] && client !== 'codex') throw failure('release_retention_client_invalid');
|
|
214
|
+
const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
|
|
215
|
+
const receiptPath = path.join(root, `.${client}-retention.json`);
|
|
216
|
+
const old = await readReceipt(receiptPath, client, pattern);
|
|
217
|
+
if (old?.contract === receiptContract(client)) return false;
|
|
218
|
+
let current = old?.current;
|
|
219
|
+
if (!current) {
|
|
220
|
+
if (typeof currentRelease !== 'string' || !path.isAbsolute(currentRelease) || !pattern.test(path.basename(currentRelease))
|
|
221
|
+
|| ![root, path.resolve(releasesRoot)].some(p => equal(path.dirname(path.resolve(currentRelease)), p))) throw failure('release_retention_target_invalid');
|
|
222
|
+
current = path.basename(currentRelease);
|
|
223
|
+
await directory(path.join(root, current));
|
|
224
|
+
}
|
|
225
|
+
await writeReceipt(receiptPath, { contract: receiptContract(client), client, current, previous: old ? old.previous : null });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const receiptContract = client => client === 'codex' ? codexContract : ENTRY[client] ? contractWithEntry : contract;
|
|
230
|
+
|
|
231
|
+
async function readReceipt(receiptPath, client, pattern) {
|
|
232
|
+
let old = null;
|
|
233
|
+
try {
|
|
234
|
+
const stat = await fs.lstat(receiptPath);
|
|
235
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 4096) throw failure('release_retention_receipt_invalid');
|
|
236
|
+
old = JSON.parse(await fs.readFile(receiptPath, 'utf8'));
|
|
237
|
+
if (![contract, contractWithEntry, ...(client === 'codex' ? [codexContract] : [])].includes(old?.contract) || old.client !== client || !pattern.test(old.current)
|
|
238
|
+
|| old.previous !== null && !pattern.test(old.previous) || old.current === old.previous) throw failure('release_retention_receipt_invalid');
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.code === 'ENOENT') return null;
|
|
241
|
+
throw error.code ? error : failure('release_retention_receipt_invalid');
|
|
242
|
+
}
|
|
243
|
+
return old;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function writeReceipt(receiptPath, receipt) {
|
|
247
|
+
const temporary = `${receiptPath}.${crypto.randomUUID()}.tmp`;
|
|
248
|
+
try {
|
|
249
|
+
await fs.writeFile(temporary, JSON.stringify(receipt) + '\n', { flag: 'wx', mode: 0o600 });
|
|
250
|
+
await fs.rename(temporary, receiptPath);
|
|
251
|
+
} finally { await fs.unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
|
|
252
|
+
}
|
|
253
|
+
|
|
124
254
|
// Uninstall keeps no release. One a process or scheduled task still uses is
|
|
125
255
|
// left for the next install or uninstall, like any other deferred release.
|
|
126
256
|
export async function removeInstalledReleases({ releasesRoot, client }, dependencies = {}) {
|
|
@@ -130,19 +260,27 @@ export async function removeInstalledReleases({ releasesRoot, client }, dependen
|
|
|
130
260
|
try { await fs.lstat(releasesRoot); } catch (error) { if (error.code === 'ENOENT') return { ...result, complete: true }; throw error; }
|
|
131
261
|
const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
|
|
132
262
|
const referenced = new Set(await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot));
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
263
|
+
const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
|
|
264
|
+
const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
|
|
265
|
+
await removeReleases(root, name => pattern.test(name), referenced, running, result, dependencies, entry.unregistered);
|
|
266
|
+
if (!result.deferred.length) {
|
|
267
|
+
await fs.rm(path.join(root, `.${client}-retention.json`), { force: true });
|
|
268
|
+
// entryReleases checked above that this is a folder in the releases root.
|
|
269
|
+
if (ENTRY[client] || client === 'codex') await fs.rm(releaseProcessDirectory(root, client), { recursive: true, force: true });
|
|
270
|
+
}
|
|
136
271
|
result.complete = result.deferred.length === 0;
|
|
137
272
|
} catch (error) { result.error = safeCode(error); }
|
|
138
273
|
return result;
|
|
139
274
|
}
|
|
140
275
|
|
|
141
|
-
|
|
276
|
+
// An unregistered entry process may run any release of its client, so then
|
|
277
|
+
// none is removed until it exits.
|
|
278
|
+
async function removeReleases(root, selected, referenced, running, result, dependencies, unregistered = false) {
|
|
142
279
|
for (const name of await fs.readdir(root)) {
|
|
143
280
|
if (!selected(name)) continue;
|
|
144
281
|
if (referenced.has(name)) { result.deferred.push({ name, reason: 'release_still_referenced' }); continue; }
|
|
145
282
|
if (running.has(name)) { result.deferred.push({ name, reason: 'release_in_use' }); continue; }
|
|
283
|
+
if (unregistered) { result.deferred.push({ name, reason: 'release_in_use_unregistered' }); continue; }
|
|
146
284
|
try {
|
|
147
285
|
// The final absolute target is a checked direct child of the canonical
|
|
148
286
|
// 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": "
|
|
7
|
+
"sha256": "5875df8d4163c0a84b3dc30265b1a5e7b977a7c92250aae013d3b513b3640c40"
|
|
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": "
|
|
55
|
+
"sha256": "9b02a42a510fa5ef4a251b6e675f9ccc60f20b718d17c1617027cd048c5634dd"
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "core/hellolynk-contact-request-e2ee.mjs",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
"path": "core/hellolynk-native-addressing-tools.cjs",
|
|
171
|
-
"sha256": "
|
|
171
|
+
"sha256": "f4bd81b3d03277e28955a62409383a0c4072c6212567f71425df4e18ece75d63"
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
"path": "core/hellolynk-native-human-confirmation-broker.mjs",
|
|
@@ -188,7 +188,7 @@
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
"path": "core/hellolynk-neutral-host-process.mjs",
|
|
191
|
-
"sha256": "
|
|
191
|
+
"sha256": "af6cb6585baae477f87ee880bd044dffb4c7f71667bfa78bcbf0d4d20e7483af"
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
"path": "core/hellolynk-neutral-host-store-adapters.mjs",
|
|
@@ -344,7 +344,7 @@
|
|
|
344
344
|
},
|
|
345
345
|
{
|
|
346
346
|
"path": "release/server/codex-neutral-production.mjs",
|
|
347
|
-
"sha256": "
|
|
347
|
+
"sha256": "ffb8e689df1b600bcca85159af8d59f0fad819827f48ba9a218a63dfd7a3daa2"
|
|
348
348
|
},
|
|
349
349
|
{
|
|
350
350
|
"path": "release/server/neutral-host-adapter.mjs",
|
|
@@ -7,6 +7,7 @@ import { discoverNeutralHost } from "./neutral-host-adapter.mjs";
|
|
|
7
7
|
import { launchPinnedNeutralHostBootstrap, verifyPinnedNeutralHostRuntime } from "../runtime/core/hellolynk-neutral-host-launcher.mjs";
|
|
8
8
|
import { resolveClientAccountRoot } from "../runtime/core/hellolynk-client-account.mjs";
|
|
9
9
|
import { startInstalledClientUpdate } from "../runtime/core/hellolynk-client-update-entry.mjs";
|
|
10
|
+
import { recordCodexReleaseProcess } from "../runtime/core/hellolynk-release-retention.mjs";
|
|
10
11
|
|
|
11
12
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const PLUGIN_ROOT = path.dirname(MODULE_DIR);
|
|
@@ -216,6 +217,7 @@ async function prepareSelectedRelease(options, verifiedInitial = null) {
|
|
|
216
217
|
assertReleaseBinding(binding, inspection);
|
|
217
218
|
const release = await loader.activateNeutralHostRelease(inspection);
|
|
218
219
|
if (!release || typeof release.run !== "function") throw codeError("neutral_codex_runner_integrity_invalid");
|
|
220
|
+
await recordCodexReleaseProcess(path.resolve(releaseRoot, "../.."));
|
|
219
221
|
return Object.freeze({ release, inspection, binding, initialBindingFingerprint,
|
|
220
222
|
releaseRoot, storeRoot, installationStore, protectedStore,
|
|
221
223
|
runnerBinding: (root) => currentRunnerBinding(storeRoot, root, binding, initialBindingFingerprint) });
|