@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 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
@@ -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
@@ -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 result = codexAppDeliveryStatus({ root: ctx.root, sessionId, initialRootIdentity: ctx.initialRootIdentity });
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
- return serveMcpStdio({ tools: service, concurrentToolCalls: true });
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, startedAt: now(), ready: false, lastError: null });
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; no idle wakeup.',
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",
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
- const metadata = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', path.join(root, 'candidate')]))[0];
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 += 2) {
110
- const key = process.argv[index], value = process.argv[index + 1];
111
- assert.ok(['--mode', '--output-dir'].includes(key) && value && !value.startsWith('--'), `Invalid option: ${key}`);
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
- const mode = options.get('--mode') || resolveAcceptanceMode({ eventName: process.env.GITHUB_EVENT_NAME,
115
- ref: process.env.GITHUB_REF, dispatchMode: process.env.HCC_DSH_DISPATCH_MODE });
116
- console.log(JSON.stringify(freezeAcceptancePackage({ root: options.get('--output-dir') || process.env.HCC_DSH_ACCEPTANCE_ROOT, mode }), null, 2));
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;