@logicseek/hello-cc 1.1.3 → 1.1.5
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/CHANGELOG.md +47 -0
- package/docs/app-bridge.md +19 -0
- package/docs/app-bridge.zh-CN.md +14 -0
- package/lib/cli/commands/app-bridge.mjs +32 -3
- package/lib/integrations/codex-app-cooperation.mjs +10 -1
- package/lib/integrations/codex-app-delivery.mjs +4 -2
- package/lib/integrations/codex-app-install.mjs +1 -1
- package/lib/integrations/codex-app-queue.mjs +28 -0
- package/lib/integrations/codex-app-recovery.mjs +159 -0
- package/package.json +3 -2
- package/scripts/dsh-package-provenance.mjs +34 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,7 @@ Before publishing, run:
|
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
npm run release:check
|
|
10
|
+
npm run release:artifact:check
|
|
10
11
|
npm run release:notes
|
|
11
12
|
npm run release:github:dry-run
|
|
12
13
|
```
|
|
@@ -16,6 +17,52 @@ the release description from the current changelog section. Use
|
|
|
16
17
|
`npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
|
|
17
18
|
backfills.
|
|
18
19
|
|
|
20
|
+
## 1.1.5
|
|
21
|
+
|
|
22
|
+
### Summary
|
|
23
|
+
|
|
24
|
+
Require a verified immutable package before npm publication or GitHub Release
|
|
25
|
+
creation so a missing release manifest fails before publication.
|
|
26
|
+
|
|
27
|
+
### Highlights
|
|
28
|
+
|
|
29
|
+
- Check the versioned frozen manifest, archive digest, packaged files and modes
|
|
30
|
+
before publishing. Reject a requested version that differs from the package.
|
|
31
|
+
- Include the frozen npm archive and file manifest used by release-mode
|
|
32
|
+
DeepSeek Harness acceptance; preserve all previously published tags and files.
|
|
33
|
+
- Keep the 1.1.4 runtime behavior, including opted-in App inbox listener recovery.
|
|
34
|
+
|
|
35
|
+
### Validation
|
|
36
|
+
|
|
37
|
+
Release preflight tests cover a matching immutable package, missing manifests,
|
|
38
|
+
source drift and version mismatches. DeepSeek Harness 0.2.0-rc.2 and 0.2.1-rc.3
|
|
39
|
+
are checked on Linux and macOS alongside repository, Web and native workflows.
|
|
40
|
+
|
|
41
|
+
## 1.1.4
|
|
42
|
+
|
|
43
|
+
### Summary
|
|
44
|
+
|
|
45
|
+
Restore explicitly enabled Codex App inbox listeners when their project HCC
|
|
46
|
+
MCP reconnects, without manually supplying the new App server PID or queue home.
|
|
47
|
+
|
|
48
|
+
### Highlights
|
|
49
|
+
|
|
50
|
+
- Add per-thread `app codex auto-enable` and `auto-disable`, with recovery state
|
|
51
|
+
in `dispatch-status`. Discover the current original App through process
|
|
52
|
+
ancestry and its open queue file; store no session capability or mail body.
|
|
53
|
+
- Revalidate the project owner, App installation, queue home, executable version
|
|
54
|
+
and fresh process identities on MCP startup. Preserve live manual listeners
|
|
55
|
+
and refuse unreviewed CLI versions or a different App installation.
|
|
56
|
+
- Close managed listeners with MCP transport shutdown. Keep durable uncertain
|
|
57
|
+
submissions across reconnects without replay, implicit ACK or thread restore.
|
|
58
|
+
|
|
59
|
+
### Validation
|
|
60
|
+
|
|
61
|
+
Lifecycle tests cover MCP reconnection, per-thread opt-in and disable, ownership
|
|
62
|
+
revocation, manual lease conflicts, exact runtime discovery and no replay after
|
|
63
|
+
an ambiguous queue outcome. Whole-App restart and cross-device acceptance remain
|
|
64
|
+
separate from these tests and from original-App MCP reconnect evidence.
|
|
65
|
+
|
|
19
66
|
## 1.1.3
|
|
20
67
|
|
|
21
68
|
### Summary
|
package/docs/app-bridge.md
CHANGED
|
@@ -165,6 +165,25 @@ explicit receiver ACK unblocks the next message. Timeouts, ambiguous results
|
|
|
165
165
|
and interrupted submissions retain `uncertain` receipts without automatic
|
|
166
166
|
replay. `dispatch-status` lists these receipts. Queued is not read or executed.
|
|
167
167
|
|
|
168
|
+
In 1.1.4, run `app codex auto-enable` in the original App terminal to opt this
|
|
169
|
+
exact current thread into recovery. The command discovers and verifies its
|
|
170
|
+
original App ancestry, executable and shared queue directory; it stores no
|
|
171
|
+
session token. The project's HCC MCP then manages the listener. When that MCP
|
|
172
|
+
reconnects after an App restart, it validates fresh process identities and
|
|
173
|
+
restores explicitly enabled threads for the same project owner and App paths.
|
|
174
|
+
An already running older MCP must reconnect once to load this feature. No hook
|
|
175
|
+
definition or trust setting is changed.
|
|
176
|
+
|
|
177
|
+
`app codex auto-disable` stops this thread's managed listener and recovery;
|
|
178
|
+
other threads, tools and mail remain. `dispatch-status` includes `recovery`
|
|
179
|
+
settings separately from the live listener. A live manual watcher is preserved;
|
|
180
|
+
recovery waits for its lease. The same audited CLI version fence still applies:
|
|
181
|
+
an unreviewed upgrade is blocked. MCP shutdown drains its own in-flight dispatch
|
|
182
|
+
and closes its listeners. Crashed or uncertain submissions are never replayed.
|
|
183
|
+
This does not cold-restore threads or deliver while the App is closed. Actual
|
|
184
|
+
whole-App restarts, individual CLI upgrades and cross-device acceptance require
|
|
185
|
+
separate evidence; MCP reconnect and simulated tests do not establish them.
|
|
186
|
+
|
|
168
187
|
Notifications include scoped commands from the exact HCC installation. The
|
|
169
188
|
`hcc_inbox` `message_id` filter reads only the selected message without ACK.
|
|
170
189
|
Ctrl-C stops only the watcher. The App must remain running with this thread
|
package/docs/app-bridge.zh-CN.md
CHANGED
|
@@ -111,6 +111,20 @@ hcc --root /absolute/project --json app codex dispatch-status
|
|
|
111
111
|
|
|
112
112
|
当前版本官方队列源码固定于 [6905ecede7816e91373e9eabda3f6b0368fc747b](https://github.com/openai/codex/blob/6905ecede7816e91373e9eabda3f6b0368fc747b/codex-rs/tui/src/session_queue_commands.rs),同版本 [watcher](https://github.com/openai/codex/blob/6905ecede7816e91373e9eabda3f6b0368fc747b/codex-rs/ext/queue/src/service.rs) 每十秒处理外部变化。OpenAI Docs 的 [App Server 文档](https://learn.chatgpt.com/docs/app-server) 描述了通用生命周期;队列路径另以这个版本的官方源码核验。
|
|
113
113
|
|
|
114
|
+
从 1.1.4 开始,可以在原 App 的当前线程内启用自动恢复:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
hcc --root /absolute/project --json app codex auto-enable
|
|
118
|
+
hcc --root /absolute/project --json app codex dispatch-status
|
|
119
|
+
hcc --root /absolute/project --json app codex auto-disable
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`auto-enable` 自动从本次终端的进程祖先识别原 App server、它正在执行的 CLI 及持有的队列文件,校验后只保存当前 `CODEX_THREAD_ID` 的启用设置,不保存 session token。该项目的 HCC MCP 启动后管理监听器;App 重启后,当插件重新连接 HCC MCP 时,会按新进程身份恢复已经单独启用的线程。需要使用包含此功能的 HCC MCP;正在运行的旧版 MCP 需要先正常重新连接一次。无需修改 Hook 定义或跳过信任审核。
|
|
123
|
+
|
|
124
|
+
恢复要求同一项目 owner、同一 App 安装路径和队列目录,仍只接受上述已审阅的 CLI 版本。新二进制会重新检查版本、文件及进程映射;未知升级版本显示 `blocked`,不能靠自动恢复绕过兼容性审核。`dispatch-status` 同时显示当前监听进程和 `recovery` 设置;保存的启用设置不代表进程正在运行。已在运行的手动监听器保持原状,自动恢复显示冲突,等其正常停止后再接管派送租约。`auto-disable` 仅关闭当前线程的自动恢复与受管监听器,其他线程、消息历史和 MCP 工具保留。
|
|
125
|
+
|
|
126
|
+
MCP 断开时会排空自己正在执行的派送并关闭受管监听器。进程异常退出时,由既有持久化记录阻止不明确派送重试;新 MCP 不会自动重发、ACK 或恢复线程。App 关闭时不派送,也不替用户加载未打开的线程。本机整 App 重启、特定升级版本和跨设备验收,应分别记录,不能由 MCP 重连或模拟测试替代。
|
|
127
|
+
|
|
114
128
|
另外仍保留端点诊断:
|
|
115
129
|
|
|
116
130
|
```sh
|
|
@@ -14,6 +14,8 @@ export function appBridgeHelp() {
|
|
|
14
14
|
hcc --root PROJECT app codex session
|
|
15
15
|
hcc --root PROJECT app codex watch --cli ABSOLUTE_APP_EXECUTABLE --sqlite-home DIRECTORY --app-server-pid PID [--poll-ms 1000]
|
|
16
16
|
hcc --root PROJECT app codex dispatch-status
|
|
17
|
+
hcc --root PROJECT app codex auto-enable
|
|
18
|
+
hcc --root PROJECT app codex auto-disable
|
|
17
19
|
hcc --root PROJECT app codex disable
|
|
18
20
|
hcc app claude capability --version VERSION
|
|
19
21
|
|
|
@@ -34,6 +36,9 @@ acknowledge; explicit reply/ack does. Optional watch forwards unread-message
|
|
|
34
36
|
notifications through the audited official CLI queue to this current App thread.
|
|
35
37
|
Keep watch running. It never acknowledges mail or starts/resumes a thread.
|
|
36
38
|
The original App must remain running with this thread loaded and uninterrupted.
|
|
39
|
+
auto-enable opts this current thread into recovery when its project MCP reconnects.
|
|
40
|
+
It discovers the original App ancestry and shared queue; no PID options are needed.
|
|
41
|
+
auto-disable stops its managed listener. Unreviewed CLI versions stay blocked.
|
|
37
42
|
|
|
38
43
|
DSH App: use the Cordis integration (hcc dsh setup --mode cordis).
|
|
39
44
|
Its inboxPollMs setting controls idle wakeup; 0 disables automatic polling.`);
|
|
@@ -44,16 +49,36 @@ export function createAppBridgeCommands() {
|
|
|
44
49
|
if (!args.length || wantsHelp(args)) return appBridgeHelp();
|
|
45
50
|
const [provider, action, ...rest] = args;
|
|
46
51
|
const opts = parseOpts(rest);
|
|
47
|
-
if (provider === 'codex' && ['setup', 'session', 'call', 'mcp', 'hook', 'disable', 'status', 'watch', 'dispatch-status'].includes(action)) {
|
|
52
|
+
if (provider === 'codex' && ['setup', 'session', 'call', 'mcp', 'hook', 'disable', 'status', 'watch', 'dispatch-status', 'auto-enable', 'auto-disable'].includes(action)) {
|
|
48
53
|
if (ctx.dbPath !== projectDbPath(ctx.root)) throw new CliError('BAD_ARGS', 'App cooperation uses the selected project managed database; omit --db');
|
|
49
54
|
const { createCodexAppCooperation, codexShellSession, codexCooperationHook } = await import('../../integrations/codex-app-cooperation.mjs');
|
|
50
55
|
const service = createCodexAppCooperation({ root: ctx.root, initialRootIdentity: ctx.initialRootIdentity });
|
|
56
|
+
if (action === 'auto-enable' || action === 'auto-disable') {
|
|
57
|
+
validateOpts(`app codex ${action}`, opts, []);
|
|
58
|
+
const sessionId = codexShellSession();
|
|
59
|
+
const { configureCodexAppRecovery } = await import('../../integrations/codex-app-recovery.mjs');
|
|
60
|
+
let runtime;
|
|
61
|
+
if (action === 'auto-enable') {
|
|
62
|
+
const { discoverCodexAppRuntime, createCodexAppQueueTransport } = await import('../../integrations/codex-app-queue.mjs');
|
|
63
|
+
runtime = discoverCodexAppRuntime();
|
|
64
|
+
await createCodexAppQueueTransport({ root: ctx.root, threadId: sessionId, ...runtime });
|
|
65
|
+
service.issueSession({ sessionId, cwd: ctx.root, source: 'codex-shell-environment' });
|
|
66
|
+
}
|
|
67
|
+
const result = configureCodexAppRecovery({ root: ctx.root, sessionId, enabled: action === 'auto-enable',
|
|
68
|
+
runtime, initialRootIdentity: ctx.initialRootIdentity });
|
|
69
|
+
printResult(ctx, result, value => value.enabled
|
|
70
|
+
? 'Automatic recovery enabled for this thread. The project HCC MCP restores its listener when it reconnects to the same supported App installation.'
|
|
71
|
+
: 'Automatic recovery disabled for this thread. Its managed listener stops; cooperation and message history remain.');
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
51
74
|
if (action === 'watch' || action === 'dispatch-status') {
|
|
52
75
|
validateOpts(`app codex ${action}`, opts, action === 'watch' ? ['cli', 'sqlite-home', 'app-server-pid', 'poll-ms'] : []);
|
|
53
76
|
const sessionId = codexShellSession();
|
|
54
77
|
const { createCodexAppDelivery, codexAppDeliveryStatus } = await import('../../integrations/codex-app-delivery.mjs');
|
|
55
78
|
if (action === 'dispatch-status') {
|
|
56
|
-
const
|
|
79
|
+
const { codexAppRecoveryStatus } = await import('../../integrations/codex-app-recovery.mjs');
|
|
80
|
+
const result = { ...codexAppDeliveryStatus({ root: ctx.root, sessionId, initialRootIdentity: ctx.initialRootIdentity }),
|
|
81
|
+
recovery: codexAppRecoveryStatus({ root: ctx.root, sessionId, initialRootIdentity: ctx.initialRootIdentity }) };
|
|
57
82
|
printResult(ctx, result, value => JSON.stringify(value));
|
|
58
83
|
return result;
|
|
59
84
|
}
|
|
@@ -97,7 +122,11 @@ export function createAppBridgeCommands() {
|
|
|
97
122
|
if (action === 'mcp') {
|
|
98
123
|
const { serveMcpStdio } = await import('../../mcp/stdio.mjs');
|
|
99
124
|
if (!service.isEnabled()) throw new CliError('CODEX_APP_SCOPE_INVALID', 'Enable cooperation for this project first');
|
|
100
|
-
|
|
125
|
+
const { createCodexAppRecoveryManager } = await import('../../integrations/codex-app-recovery.mjs');
|
|
126
|
+
const recovery = createCodexAppRecoveryManager({ root: ctx.root, initialRootIdentity: ctx.initialRootIdentity,
|
|
127
|
+
onStatus: value => process.stderr.write(JSON.stringify(value) + '\n') });
|
|
128
|
+
try { return await serveMcpStdio({ tools: service, concurrentToolCalls: true }); }
|
|
129
|
+
finally { await recovery.close(); }
|
|
101
130
|
}
|
|
102
131
|
if (action === 'hook') {
|
|
103
132
|
let text = '';
|
|
@@ -189,6 +189,15 @@ export function createCodexAppCooperation({ root, initialRootIdentity = null, no
|
|
|
189
189
|
return { scope: { root: ctx.root, dbPath: ctx.dbPath, peer, sessionId, executorId: expected },
|
|
190
190
|
connect, assertOwnership };
|
|
191
191
|
}
|
|
192
|
+
function recoverySettings() {
|
|
193
|
+
const db = connect();
|
|
194
|
+
try {
|
|
195
|
+
enabled(db);
|
|
196
|
+
const prefix = 'codex-app.dispatch.recovery.';
|
|
197
|
+
return db.prepare('SELECT value FROM meta WHERE substr(key,1,?)=? LIMIT 100').all(prefix.length, prefix)
|
|
198
|
+
.map(row => JSON.parse(row.value));
|
|
199
|
+
} finally { db.close(); }
|
|
200
|
+
}
|
|
192
201
|
function owned(auth, operation, transactional = false) {
|
|
193
202
|
const db = connect();
|
|
194
203
|
try { const run = () => { auth.assertOwnership(db); return operation(db); }; return transactional ? tx(db, run) : run(); }
|
|
@@ -270,7 +279,7 @@ export function createCodexAppCooperation({ root, initialRootIdentity = null, no
|
|
|
270
279
|
}
|
|
271
280
|
}
|
|
272
281
|
return { ctx, enable, disable, isEnabled, issueSession, authority, deliveryAuthority, call,
|
|
273
|
-
list: () => structuredClone(definitions), has: name => definitions.some(item => item.name === name) };
|
|
282
|
+
recoverySettings, list: () => structuredClone(definitions), has: name => definitions.some(item => item.name === name) };
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
export function codexShellSession(env = process.env) {
|
|
@@ -35,6 +35,7 @@ export function codexDeliveryNotice({ root, sessionId, messageId }) {
|
|
|
35
35
|
// Intent is durable before invoking the external queue. Ambiguous submissions
|
|
36
36
|
// are retained without retry; only the receiving session can ACK the HCC mail.
|
|
37
37
|
export function createCodexAppDelivery({ root, sessionId, transport, initialRootIdentity = null,
|
|
38
|
+
recoveryPolicyId = null,
|
|
38
39
|
pollMs = 1000, now = () => Math.floor(Date.now() / 1000), inspect = inspectProcessIdentity,
|
|
39
40
|
schedule = setInterval, unschedule = clearInterval, onStatus = () => {} } = {}) {
|
|
40
41
|
if (!transport || typeof transport.enqueue !== 'function' || typeof transport.assertValid !== 'function' ||
|
|
@@ -65,7 +66,8 @@ export function createCodexAppDelivery({ root, sessionId, transport, initialRoot
|
|
|
65
66
|
const item = JSON.parse(row.value);
|
|
66
67
|
if (item.status === 'dispatching') save(db, row.key, { ...item, status: 'uncertain', reason: 'dispatcher_restarted', updatedAt: now() });
|
|
67
68
|
}
|
|
68
|
-
save(db, key, { version: 1, owner, identity: identity.identity, sessionId,
|
|
69
|
+
save(db, key, { version: 1, owner, identity: identity.identity, sessionId, recoveryPolicyId,
|
|
70
|
+
startedAt: now(), ready: false, lastError: null });
|
|
69
71
|
}, false);
|
|
70
72
|
|
|
71
73
|
async function runTick() {
|
|
@@ -153,7 +155,7 @@ export function codexAppDeliveryStatus({ root, sessionId, initialRootIdentity =
|
|
|
153
155
|
const running = observed?.state === 'live' && compareProcessIdentity(saved.identity, observed.identity) === 'live';
|
|
154
156
|
const prefix = deliveryPrefix(auth.scope.peer);
|
|
155
157
|
return { peer: auth.scope.peer, sessionId, running: Boolean(running), ready: Boolean(running && saved.ready), error: saved?.lastError || null,
|
|
156
|
-
dispatcherPid: saved?.identity?.pid || null,
|
|
158
|
+
dispatcherPid: saved?.identity?.pid || null, recoveryPolicyId: running ? saved?.recoveryPolicyId || null : null,
|
|
157
159
|
deliveries: db.prepare('SELECT value FROM meta WHERE substr(key,1,?)=?').all(prefix.length, prefix).map(row => JSON.parse(row.value)) };
|
|
158
160
|
} finally { db.close(); }
|
|
159
161
|
}
|
|
@@ -31,7 +31,7 @@ export function writeCodexCooperationPlugin({ root, directory, initialRootIdenti
|
|
|
31
31
|
plugins: [{ name: pluginName, source: { source: 'local', path: './plugin' },
|
|
32
32
|
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Developer Tools' }] });
|
|
33
33
|
write('plugin/.codex-plugin/plugin.json', { name: pluginName, version: '1.0.0',
|
|
34
|
-
description: 'Coordinate this project from the original Codex App session. Explicit session capabilities;
|
|
34
|
+
description: 'Coordinate this project from the original Codex App session. Explicit session capabilities; optional inbox delivery.',
|
|
35
35
|
author: { name: 'hello-cc' }, hooks: './hooks/hooks.json' });
|
|
36
36
|
write('plugin/.mcp.json', { mcpServers: { hcc_cooperation: { command: prefix[0], args: [...prefix.slice(1), 'app', 'codex', 'mcp'],
|
|
37
37
|
cwd: service.ctx.root } } });
|
|
@@ -29,6 +29,34 @@ function runSync(command, args) {
|
|
|
29
29
|
return result.stdout.trim();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Discover only this process's ancestors. A newly launched project MCP can
|
|
33
|
+
// renew an opted-in delivery after an App restart without selecting a thread
|
|
34
|
+
// from a shared session_id or scanning other applications and sockets.
|
|
35
|
+
export function discoverCodexAppRuntime({ pid = process.pid, sync = runSync,
|
|
36
|
+
realpath = fs.realpathSync, platform = process.platform } = {}) {
|
|
37
|
+
if (platform !== 'darwin') throw new CliError('CODEX_APP_QUEUE_UNSUPPORTED', 'Automatic App delivery is currently verified only on macOS');
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (let depth = 0; depth < 24 && Number.isSafeInteger(pid) && pid > 1 && !seen.has(pid); depth++) {
|
|
40
|
+
seen.add(pid);
|
|
41
|
+
const match = sync('/bin/ps', ['-p', String(pid), '-o', 'ppid=', '-o', 'comm=']).match(/^\s*(\d+)\s+(.+)$/);
|
|
42
|
+
if (!match) throw invalid();
|
|
43
|
+
const parent = Number(match[1]), executable = match[2];
|
|
44
|
+
if (path.basename(executable) === 'codex') {
|
|
45
|
+
const appExecutable = sync('/bin/ps', ['-p', String(parent), '-o', 'comm=']);
|
|
46
|
+
if (/\/(?:Codex|ChatGPT)\.app\/Contents\/MacOS\/(?:Codex|ChatGPT)$/.test(appExecutable)) {
|
|
47
|
+
const files = sync('/usr/sbin/lsof', ['-nP', '-a', '-p', String(pid), '-F', 'n']);
|
|
48
|
+
const queues = [...new Set(files.split('\n').filter(line => line.startsWith('n/') &&
|
|
49
|
+
path.basename(line.slice(1)) === 'queue_1.sqlite').map(line => realpath(line.slice(1))))];
|
|
50
|
+
if (queues.length !== 1) throw invalid();
|
|
51
|
+
return { cli: realpath(executable), appServerPid: pid, sqliteHome: path.dirname(queues[0]),
|
|
52
|
+
appExecutable: realpath(appExecutable) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
pid = parent;
|
|
56
|
+
}
|
|
57
|
+
throw invalid();
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
// Uses the audited official queue CLI; never writes Codex SQLite or sends
|
|
33
61
|
// thread/start, thread/resume, turn/start or a private socket request. Queued
|
|
34
62
|
// means only queued. The original App and explicit HCC ACK prove consumption.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { CliError } from '../shared/errors.mjs';
|
|
4
|
+
import { tx } from '../db/schema.mjs';
|
|
5
|
+
import { createCodexAppCooperation } from './codex-app-cooperation.mjs';
|
|
6
|
+
import { createCodexAppDelivery, codexAppDeliveryStatus } from './codex-app-delivery.mjs';
|
|
7
|
+
import { createCodexAppQueueTransport, discoverCodexAppRuntime } from './codex-app-queue.mjs';
|
|
8
|
+
|
|
9
|
+
const prefix = 'codex-app.dispatch.recovery.';
|
|
10
|
+
const key = auth => prefix + auth.scope.peer;
|
|
11
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const get = (db, auth) => {
|
|
13
|
+
const row = db.prepare('SELECT value FROM meta WHERE key=?').get(key(auth));
|
|
14
|
+
return row ? JSON.parse(row.value) : null;
|
|
15
|
+
};
|
|
16
|
+
const put = (db, auth, value) => db.prepare('INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
|
17
|
+
.run(key(auth), JSON.stringify(value));
|
|
18
|
+
function owned(auth, operation) {
|
|
19
|
+
const db = auth.connect();
|
|
20
|
+
try { return tx(db, () => { auth.assertOwnership(db); return operation(db); }); }
|
|
21
|
+
finally { db.close(); }
|
|
22
|
+
}
|
|
23
|
+
const stateOf = setting => setting ? { enabled: setting.enabled, status: setting.status,
|
|
24
|
+
error: setting.error || null, lastStartedAt: setting.lastStartedAt || null,
|
|
25
|
+
activeHostPid: setting.activeHostPid || null } : { enabled: false, status: 'disabled', error: null };
|
|
26
|
+
|
|
27
|
+
// This opt-in names one previously enrolled thread. It contains no model
|
|
28
|
+
// capability, hook session_id, message body, or permission to restore a thread.
|
|
29
|
+
export function configureCodexAppRecovery({ root, sessionId, enabled, runtime,
|
|
30
|
+
initialRootIdentity = null, now = () => Math.floor(Date.now() / 1000) } = {}) {
|
|
31
|
+
if (!uuid.test(sessionId || '') || typeof enabled !== 'boolean' || enabled &&
|
|
32
|
+
!['cli', 'sqliteHome', 'appExecutable'].every(name => typeof runtime?.[name] === 'string' && path.isAbsolute(runtime[name]))) {
|
|
33
|
+
throw new CliError('BAD_ARGS', 'Automatic recovery requires this current App thread and a verified original runtime');
|
|
34
|
+
}
|
|
35
|
+
const service = createCodexAppCooperation({ root, initialRootIdentity });
|
|
36
|
+
const auth = service.deliveryAuthority(sessionId);
|
|
37
|
+
return owned(auth, db => {
|
|
38
|
+
const previous = get(db, auth);
|
|
39
|
+
const setting = { version: 1, policyId: randomUUID(), sessionId, peer: auth.scope.peer, owner: auth.scope.executorId,
|
|
40
|
+
enabled, status: enabled ? 'pending' : 'disabled', error: null, updatedAt: now(),
|
|
41
|
+
...(enabled ? { runtime: { cli: runtime.cli, sqliteHome: runtime.sqliteHome, appExecutable: runtime.appExecutable } } : {}),
|
|
42
|
+
createdAt: previous?.createdAt || now() };
|
|
43
|
+
put(db, auth, setting);
|
|
44
|
+
return { root: auth.scope.root, sessionId, peer: auth.scope.peer, ...stateOf(setting) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function codexAppRecoveryStatus({ root, sessionId, initialRootIdentity = null } = {}) {
|
|
49
|
+
const auth = createCodexAppCooperation({ root, initialRootIdentity }).deliveryAuthority(sessionId);
|
|
50
|
+
const setting = owned(auth, db => get(db, auth));
|
|
51
|
+
if (setting && setting.owner !== auth.scope.executorId) return { enabled: false, status: 'revoked', error: 'CODEX_APP_SCOPE_INVALID' };
|
|
52
|
+
const state = stateOf(setting);
|
|
53
|
+
if (state.status === 'active') {
|
|
54
|
+
const listener = codexAppDeliveryStatus({ root, sessionId, initialRootIdentity });
|
|
55
|
+
if (!listener.running || listener.recoveryPolicyId !== setting.policyId) return { ...state,
|
|
56
|
+
status: listener.running ? 'blocked' : 'pending', activeHostPid: null,
|
|
57
|
+
error: listener.running ? 'CODEX_APP_DISPATCH_CONFLICT' : null };
|
|
58
|
+
}
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The original App starts this project MCP again when its plugin reconnects.
|
|
63
|
+
// That new process supplies fresh App/server ancestry. Only explicitly saved
|
|
64
|
+
// policies for this project and exact executable/home may start a dispatcher.
|
|
65
|
+
// The manager owns no App lifecycle and never starts/resumes/forks a thread.
|
|
66
|
+
export function createCodexAppRecoveryManager({ root, initialRootIdentity = null,
|
|
67
|
+
discover = discoverCodexAppRuntime, createTransport = createCodexAppQueueTransport,
|
|
68
|
+
createDelivery = createCodexAppDelivery, schedule = setInterval, unschedule = clearInterval,
|
|
69
|
+
now = () => Math.floor(Date.now() / 1000), onStatus = () => {} } = {}) {
|
|
70
|
+
const service = createCodexAppCooperation({ root, initialRootIdentity });
|
|
71
|
+
const active = new Map();
|
|
72
|
+
let closed = false, closing = false, currentTick = null, runtime = null;
|
|
73
|
+
function update(auth, setting, values) {
|
|
74
|
+
owned(auth, db => {
|
|
75
|
+
const current = get(db, auth);
|
|
76
|
+
if (!current || current.owner !== setting.owner || current.policyId !== setting.policyId || !current.enabled) return;
|
|
77
|
+
if (Object.entries(values).every(([name, value]) => current[name] === value)) return;
|
|
78
|
+
put(db, auth, { ...current, ...values });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const notify = value => { try { onStatus(value); } catch {} };
|
|
82
|
+
async function runTick() {
|
|
83
|
+
let settings;
|
|
84
|
+
try { settings = service.isEnabled() ? service.recoverySettings() : []; }
|
|
85
|
+
catch (error) {
|
|
86
|
+
for (const item of active.values()) await item.delivery.close();
|
|
87
|
+
active.clear();
|
|
88
|
+
notify({ error: error instanceof CliError ? error.code : 'CODEX_APP_RECOVERY_FAILED' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const enabled = new Map(settings.filter(item => item.enabled === true).map(item => [item.peer, item]));
|
|
92
|
+
for (const [peer, item] of active) {
|
|
93
|
+
const setting = enabled.get(peer);
|
|
94
|
+
if (!setting || setting.owner !== item.setting.owner || setting.policyId !== item.setting.policyId) {
|
|
95
|
+
await item.delivery.close(); active.delete(peer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const setting of enabled.values()) {
|
|
99
|
+
if (closing) break;
|
|
100
|
+
let auth;
|
|
101
|
+
try {
|
|
102
|
+
auth = service.deliveryAuthority(setting.sessionId);
|
|
103
|
+
if (setting.version !== 1 || !uuid.test(setting.policyId || '') || setting.peer !== auth.scope.peer || setting.owner !== auth.scope.executorId) {
|
|
104
|
+
throw new CliError('CODEX_APP_SCOPE_INVALID', 'Automatic recovery policy belongs to a previous cooperation owner');
|
|
105
|
+
}
|
|
106
|
+
if (!active.has(setting.peer)) {
|
|
107
|
+
const existing = codexAppDeliveryStatus({ root: service.ctx.root, sessionId: setting.sessionId, initialRootIdentity });
|
|
108
|
+
if (existing.running && existing.recoveryPolicyId === setting.policyId) {
|
|
109
|
+
// Several App threads can hold a project MCP connection. Reuse
|
|
110
|
+
// that policy's live listener without changing or closing its lease.
|
|
111
|
+
update(auth, setting, { status: existing.ready ? 'active' : 'blocked',
|
|
112
|
+
error: existing.error, activeHostPid: existing.dispatcherPid });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (existing.running) throw new CliError('CODEX_APP_DISPATCH_CONFLICT', 'A different live dispatcher owns this thread');
|
|
116
|
+
runtime ||= await discover();
|
|
117
|
+
if (!['cli', 'sqliteHome', 'appExecutable'].every(name => runtime[name] === setting.runtime?.[name])) {
|
|
118
|
+
throw new CliError('CODEX_APP_RECOVERY_RUNTIME_CHANGED', 'This MCP belongs to a different App installation or queue home');
|
|
119
|
+
}
|
|
120
|
+
const transport = await createTransport({ root: service.ctx.root, threadId: setting.sessionId, ...runtime });
|
|
121
|
+
if (closing) break;
|
|
122
|
+
const delivery = createDelivery({ root: service.ctx.root, sessionId: setting.sessionId,
|
|
123
|
+
initialRootIdentity, transport, recoveryPolicyId: setting.policyId,
|
|
124
|
+
schedule: () => null, unschedule: () => {}, onStatus: notify });
|
|
125
|
+
active.set(setting.peer, { delivery, setting });
|
|
126
|
+
update(auth, setting, { status: 'active', error: null, lastStartedAt: now(), activeHostPid: process.pid });
|
|
127
|
+
notify({ peer: setting.peer, status: 'active', automaticRecovery: true });
|
|
128
|
+
}
|
|
129
|
+
const item = active.get(setting.peer);
|
|
130
|
+
await item.delivery.tick();
|
|
131
|
+
if (item.delivery.status().error) {
|
|
132
|
+
throw new CliError(item.delivery.status().error, 'Automatic delivery stopped pending runtime validation');
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const item = active.get(setting.peer);
|
|
136
|
+
if (item) { await item.delivery.close(); active.delete(setting.peer); }
|
|
137
|
+
const code = error instanceof CliError ? error.code : 'CODEX_APP_RECOVERY_FAILED';
|
|
138
|
+
// Failures never break the MCP protocol or relax its per-call scope.
|
|
139
|
+
if (auth) try { update(auth, setting, { status: 'blocked', error: code, activeHostPid: null }); } catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function tick() {
|
|
144
|
+
if (closed || closing) return Promise.resolve();
|
|
145
|
+
if (!currentTick) currentTick = runTick().finally(() => { currentTick = null; });
|
|
146
|
+
return currentTick;
|
|
147
|
+
}
|
|
148
|
+
const timer = schedule(() => { void tick().catch(() => {}); }, 1000);
|
|
149
|
+
void tick().catch(() => {});
|
|
150
|
+
return {
|
|
151
|
+
tick,
|
|
152
|
+
async close() {
|
|
153
|
+
if (closed) return;
|
|
154
|
+
closing = true; unschedule(timer); await currentTick;
|
|
155
|
+
for (const item of active.values()) await item.delivery.close();
|
|
156
|
+
active.clear(); closed = true;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logicseek/hello-cc",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"release:notes": "node ./scripts/release-notes.mjs",
|
|
29
29
|
"release:check": "node ./scripts/release-notes.mjs --check",
|
|
30
|
+
"release:artifact:check": "node ./scripts/dsh-package-provenance.mjs --check-release",
|
|
30
31
|
"release:github": "node ./scripts/github-release.mjs",
|
|
31
32
|
"release:github:dry-run": "node ./scripts/github-release.mjs --dry-run",
|
|
32
33
|
"smoke": "node ./bin/hcc.mjs --help",
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"build:native:cwd-handoff": "node ./scripts/build-cwd-handoff.mjs",
|
|
42
43
|
"release:native:verify": "node ./scripts/verify-cwd-handoff-package.mjs",
|
|
43
44
|
"release:native:pack": "node ./scripts/pack-cwd-handoff.mjs",
|
|
44
|
-
"prepublishOnly": "npm run release:native:verify"
|
|
45
|
+
"prepublishOnly": "npm run release:check && npm run release:artifact:check && npm run release:native:verify"
|
|
45
46
|
},
|
|
46
47
|
"repository": {
|
|
47
48
|
"type": "git",
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// Harness, calling a model, changing an archive, or publishing a package.
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
import { execFileSync } from 'node:child_process';
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
10
|
import { DSH_BASELINE_VERSION, DSH_SUPPORTED_VERSIONS } from '../lib/integrations/dsh-versions.mjs';
|
|
11
|
+
import { normalizeVersion } from '../lib/release/release-notes.mjs';
|
|
10
12
|
|
|
11
13
|
const ARCHIVED_BASELINE = 'docs/verification/dsh-1.0.3-package.json';
|
|
12
14
|
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
@@ -69,7 +71,9 @@ export function freezeAcceptancePackage({ root, repo = fileURLToPath(new URL('..
|
|
|
69
71
|
const run = (command, args) => execFileSync(command, args, { cwd: repo, env: childEnv, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
70
72
|
const npm = args => env.npm_execpath
|
|
71
73
|
? run(process.execPath, [env.npm_execpath, ...args]) : run('npm', args);
|
|
72
|
-
|
|
74
|
+
// A parent npm publish --dry-run exports npm_config_dry_run. The preflight
|
|
75
|
+
// still needs real local archive bytes in order to verify the package.
|
|
76
|
+
const metadata = JSON.parse(npm(['pack', '--ignore-scripts', '--dry-run=false', '--json', '--pack-destination', path.join(root, 'candidate')]))[0];
|
|
73
77
|
if (env.GITHUB_REF?.startsWith('refs/tags/v')) {
|
|
74
78
|
assert.equal(mode, 'release', 'Version tags require immutable release acceptance');
|
|
75
79
|
assert.equal(env.GITHUB_REF.slice('refs/tags/v'.length), metadata.version, 'Version tag must match the package version');
|
|
@@ -103,17 +107,40 @@ export function freezeAcceptancePackage({ root, repo = fileURLToPath(new URL('..
|
|
|
103
107
|
return receipt;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
export function checkReleaseArtifact({ repo, env = process.env, version } = {}) {
|
|
111
|
+
const expectedVersion = version ? normalizeVersion(version) : null;
|
|
112
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-release-artifact-check-'));
|
|
113
|
+
try {
|
|
114
|
+
const receipt = freezeAcceptancePackage({ repo, root, mode: 'release',
|
|
115
|
+
env: { ...env, GITHUB_OUTPUT: '' } });
|
|
116
|
+
if (expectedVersion) assert.equal(receipt.package.version, expectedVersion,
|
|
117
|
+
'Requested release must match the checked package version');
|
|
118
|
+
return receipt;
|
|
119
|
+
} finally {
|
|
120
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
107
125
|
try {
|
|
108
126
|
const options = new Map();
|
|
109
|
-
for (let index = 2; index < process.argv.length; index +=
|
|
110
|
-
const key = process.argv[index]
|
|
111
|
-
|
|
127
|
+
for (let index = 2; index < process.argv.length; index += 1) {
|
|
128
|
+
const key = process.argv[index];
|
|
129
|
+
if (key === '--check-release') { options.set(key, true); continue; }
|
|
130
|
+
const value = process.argv[++index];
|
|
131
|
+
assert.ok(['--mode', '--output-dir', '--version'].includes(key) && value && !value.startsWith('--'), `Invalid option: ${key}`);
|
|
112
132
|
options.set(key, value);
|
|
113
133
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
if (options.get('--check-release')) {
|
|
135
|
+
assert.ok(!options.has('--mode') && !options.has('--output-dir'),
|
|
136
|
+
'--check-release uses release mode and its own temporary directory');
|
|
137
|
+
console.log(JSON.stringify(checkReleaseArtifact({ version: options.get('--version') }), null, 2));
|
|
138
|
+
} else {
|
|
139
|
+
assert.ok(!options.has('--version'), '--version requires --check-release');
|
|
140
|
+
const mode = options.get('--mode') || resolveAcceptanceMode({ eventName: process.env.GITHUB_EVENT_NAME,
|
|
141
|
+
ref: process.env.GITHUB_REF, dispatchMode: process.env.HCC_DSH_DISPATCH_MODE });
|
|
142
|
+
console.log(JSON.stringify(freezeAcceptancePackage({ root: options.get('--output-dir') || process.env.HCC_DSH_ACCEPTANCE_ROOT, mode }), null, 2));
|
|
143
|
+
}
|
|
117
144
|
} catch (error) {
|
|
118
145
|
const details = String(error.stack || error.message || error).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
119
146
|
console.error(`::error::${details}`); process.exitCode = 1;
|