@logicseek/hello-cc 0.1.8 → 0.1.9

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
@@ -16,6 +16,64 @@ the release description from the current changelog section. Use
16
16
  `npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
17
17
  backfills.
18
18
 
19
+ ## 0.1.9
20
+
21
+ ### Summary
22
+
23
+ hello-cc 0.1.9 publishes the post-0.1.8 Web runtime and provider shim
24
+ hardening work. It tightens Web peer action identity, makes runtime cleanup
25
+ safer, and keeps Claude/Codex shims from attaching unrelated projects to a
26
+ global Web runtime.
27
+
28
+ ### Highlights
29
+
30
+ - Hardened Web runtime cleanup so starting `hcc web` from a wrapper shell does
31
+ not terminate the current parent process chain while still removing stale
32
+ orphan runtimes for the same project.
33
+ - Returned structured `BAD_REQUEST` JSON for malformed Web API request bodies
34
+ instead of surfacing raw JSON parse failures.
35
+ - Replaced Web-runtime-token reuse for mutating peer actions with independent
36
+ per-session action tokens.
37
+ - Changed provider shims to fall back to the real Claude/Codex CLI when no
38
+ current-project Web runtime is available.
39
+ - Made provider shims use only the current project's `.hello-cc/runtime.json`
40
+ during managed launches, avoiding accidental attachment through a global Web
41
+ runtime from another project.
42
+ - Kept shim-only environment variables out of restarted provider sessions.
43
+
44
+ ### Compatibility Notes
45
+
46
+ - No breaking CLI command changes are intended in this release.
47
+ - `hcc web --local` is still Web mode; use `hcc up` when you only want local
48
+ coordination commands without the Web console or shims.
49
+ - Starting `claude` or `codex` in a directory without a local
50
+ `.hello-cc/runtime.json` now runs the real provider CLI instead of implicitly
51
+ using a global hello-cc Web runtime.
52
+
53
+ ### Validation
54
+
55
+ The 0.1.9 release should be validated with:
56
+
57
+ ```bash
58
+ git diff --check
59
+ node --check bin/hcc.mjs
60
+ find lib -name '*.mjs' -print0 | xargs -0 -n1 node --check
61
+ node --check scripts/github-release.mjs
62
+ node --check scripts/regression.mjs
63
+ node --check scripts/release-notes.mjs
64
+ npm run release:check
65
+ npm run release:github:dry-run -- --version 0.1.9
66
+ npm pack --dry-run --json
67
+ npm publish --dry-run --registry=https://registry.npmjs.org/ --access public
68
+ npm test
69
+ ```
70
+
71
+ The expected full regression marker is:
72
+
73
+ ```text
74
+ FULL_REGRESSION_OK
75
+ ```
76
+
19
77
  ## 0.1.8
20
78
 
21
79
  ### Summary
package/README.md CHANGED
@@ -107,6 +107,9 @@ local: http://127.0.0.1:8787/?token=<saved-token>&project=/path/to/project
107
107
  Use `--local` to bind only to `127.0.0.1`, or `--port N` to request a specific
108
108
  port. `hcc web` initializes the project bus, installs Claude/Codex hooks and
109
109
  shims, starts or reuses the Web console, and returns the terminal to you.
110
+ `hcc web --local` is still Web mode; it only limits the listener to loopback.
111
+ Use `hcc up` when you want local coordination commands without the Web console
112
+ or shims.
110
113
 
111
114
  After the first shim install, open a new terminal or reload the rc file for
112
115
  your shell:
@@ -125,7 +128,11 @@ codex resume <session-id>
125
128
  ```
126
129
 
127
130
  Those sessions become tmux-backed peers that can be seen and controlled from
128
- Web while remaining usable from the local terminal.
131
+ Web while remaining usable from the local terminal. The shims only use the
132
+ current project's runtime file. If you start `claude` or `codex` in a directory
133
+ where `hcc web` has not created `.hello-cc/runtime.json`, the shim falls back to
134
+ the real provider CLI instead of using the global Web runtime or creating a new
135
+ project database.
129
136
 
130
137
  ## Basic Workflow
131
138
 
package/README.zh-CN.md CHANGED
@@ -94,6 +94,8 @@ local: http://127.0.0.1:8787/?token=<saved-token>&project=/path/to/project
94
94
  使用 `--local` 可以只绑定 `127.0.0.1`,使用 `--port N` 可以指定请求端口。
95
95
  `hcc web` 会初始化项目总线,安装 Claude/Codex hooks 和 shims,启动或复用 Web
96
96
  控制台,然后把终端还给你。
97
+ `hcc web --local` 仍然是 Web 模式,只是限制监听在本机 loopback。只想使用本地
98
+ 协作命令、不启动 Web 控制台、不安装 shims 时,使用 `hcc up`。
97
99
 
98
100
  第一次安装 shim 后,打开新终端,或根据当前 shell 重新加载配置:
99
101
 
@@ -111,6 +113,9 @@ codex resume <session-id>
111
113
  ```
112
114
 
113
115
  这些会话会成为 tmux-backed peer,可以继续在本地终端里使用,也可以被 Web 控制台观察和操作。
116
+ shim 只使用当前项目自己的 runtime 文件。如果你在没有通过 `hcc web` 创建
117
+ `.hello-cc/runtime.json` 的目录里启动 `claude` 或 `codex`,shim 会回退到真实
118
+ provider CLI,不会使用全局 Web runtime,也不会为这个目录新建项目数据库。
114
119
 
115
120
  ## 基本流程
116
121
 
package/bin/hcc.mjs CHANGED
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
+ import { randomBytes } from 'node:crypto';
7
8
  import { spawn, spawnSync } from 'node:child_process';
8
9
  import { setTimeout as sleep } from 'node:timers/promises';
9
10
  import { URL, fileURLToPath } from 'node:url';
@@ -1417,15 +1418,28 @@ async function cmdBroadcast(ctx, args) {
1417
1418
  touchCurrentPeer(db, ctx, identity, null, 'shell');
1418
1419
  const id = sendMessage(db, sender, 'all', taskId, kind, body);
1419
1420
  let injected = 0;
1421
+ let skipped = 0;
1420
1422
  if (opts.inject) {
1421
1423
  const sessions = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
1422
1424
  const running = (sessions.sessions || []).filter((session) => session.status === 'running');
1423
1425
  for (const session of running) {
1424
- await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
1425
- injected += 1;
1426
+ try {
1427
+ await injectPeer(ctx, session.id, body, !opts['no-enter'], runtime, sender);
1428
+ injected += 1;
1429
+ } catch (err) {
1430
+ if (err instanceof CliError && ['NOT_FOUND', 'SESSION_NOT_RUNNING', 'TMUX_ERROR'].includes(err.code)) {
1431
+ skipped += 1;
1432
+ continue;
1433
+ }
1434
+ throw err;
1435
+ }
1426
1436
  }
1427
1437
  }
1428
- printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected }, (data) => `broadcast message #${data.id}${data.injected ? ` and injected ${data.injected} terminal(s)` : ''}`);
1438
+ printResult(ctx, { id, sender, recipient: 'all', task_id: taskId, kind, body, injected, skipped }, (data) => {
1439
+ const injectedText = data.injected ? ` and injected ${data.injected} terminal(s)` : '';
1440
+ const skippedText = data.skipped ? `, skipped ${data.skipped} stale terminal(s)` : '';
1441
+ return `broadcast message #${data.id}${injectedText}${skippedText}`;
1442
+ });
1429
1443
  }
1430
1444
 
1431
1445
  async function injectPeer(ctx, peer, text, enter = true, runtime = null, auditActor = null) {
@@ -2044,11 +2058,21 @@ function hccWebProcessMatches(line, ctx) {
2044
2058
  sameResolvedPath(global.db, ctx.dbPath);
2045
2059
  }
2046
2060
 
2061
+ function currentProcessAncestorPids(ppidByPid) {
2062
+ const ancestors = new Set();
2063
+ let pid = process.ppid;
2064
+ while (Number.isFinite(pid) && pid > 0 && !ancestors.has(pid)) {
2065
+ ancestors.add(pid);
2066
+ pid = ppidByPid.get(pid);
2067
+ }
2068
+ return ancestors;
2069
+ }
2070
+
2047
2071
  async function stopOrphanWebRuntimes(ctx, keepPid = null) {
2048
2072
  if (process.platform === 'win32') return;
2049
2073
  let output = '';
2050
2074
  try {
2051
- output = spawnSync('ps', ['-eo', 'pid=,args='], {
2075
+ output = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], {
2052
2076
  encoding: 'utf8',
2053
2077
  stdio: ['ignore', 'pipe', 'ignore']
2054
2078
  }).stdout || '';
@@ -2056,15 +2080,25 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
2056
2080
  return;
2057
2081
  }
2058
2082
 
2059
- const pids = [];
2083
+ const rows = [];
2084
+ const ppidByPid = new Map();
2060
2085
  for (const line of output.split('\n')) {
2061
2086
  const trimmed = line.trim();
2062
2087
  if (!trimmed) continue;
2063
- const match = trimmed.match(/^(\d+)\s+(.*)$/);
2088
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
2064
2089
  if (!match) continue;
2065
2090
  const pid = Number.parseInt(match[1], 10);
2066
- if (!Number.isFinite(pid) || pid === process.pid || pid === keepPid) continue;
2067
- if (hccWebProcessMatches(match[2], ctx)) pids.push(pid);
2091
+ const ppid = Number.parseInt(match[2], 10);
2092
+ if (!Number.isFinite(pid)) continue;
2093
+ if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
2094
+ rows.push({ pid, args: match[3] });
2095
+ }
2096
+
2097
+ const ancestorPids = currentProcessAncestorPids(ppidByPid);
2098
+ const pids = [];
2099
+ for (const row of rows) {
2100
+ if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
2101
+ if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
2068
2102
  }
2069
2103
  if (!pids.length) return;
2070
2104
 
@@ -2250,7 +2284,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
2250
2284
  const prepared = await prepareLocalBus(ctx, opts);
2251
2285
 
2252
2286
  function newSessionActionToken() {
2253
- return makeWebToken({ token: null });
2287
+ return randomBytes(32).toString('base64url');
2254
2288
  }
2255
2289
 
2256
2290
  function rememberProject(projectCtx) {
package/docs/commands.md CHANGED
@@ -29,7 +29,9 @@ shims, starts or reuses the Web console, and returns the terminal to you. Bare
29
29
  first use. Use `--local` to bind only `127.0.0.1`, `--token` or `HCC_WEB_TOKEN`
30
30
  to replace the saved token, and
31
31
  `--no-token` only in trusted local/test environments. Use `hcc up` only when you
32
- want coordination without the Web console or shims.
32
+ want coordination without the Web console or shims. Provider shims only join
33
+ projects with a local `.hello-cc/runtime.json` from `hcc web`; they do not use a
34
+ global runtime to manage arbitrary directories.
33
35
 
34
36
  ## Peers And Status
35
37
 
@@ -26,7 +26,9 @@ hcc up [--no-discover] [--no-guidance]
26
26
  Web 控制台,然后把终端还给你。裸 `hcc web` 会监听 `0.0.0.0` 并使用固定
27
27
  URL token;首次使用时自动生成并保存。用 `--local` 可只绑定 `127.0.0.1`,
28
28
  用 `--token` 或 `HCC_WEB_TOKEN` 可替换保存的 token,只有在可信本地/测试环境才使用 `--no-token`。
29
- 只想使用本地协作、不需要 Web 或 shims 时,再使用 `hcc up`。
29
+ 只想使用本地协作、不需要 Web 或 shims 时,再使用 `hcc up`。provider shim 只会加入
30
+ 已经由 `hcc web` 生成本地 `.hello-cc/runtime.json` 的项目,不会使用全局 runtime
31
+ 去管理任意目录。
30
32
 
31
33
  ## Peers 和状态
32
34
 
package/docs/guide.md CHANGED
@@ -104,6 +104,10 @@ The wrappers create or reuse local persistent tmux terminals:
104
104
  - The local terminal attaches to the same tmux session and remains usable.
105
105
  - The web console controls the same tmux pane, not a temporary browser-only
106
106
  terminal.
107
+ - Shims only use the current project's `.hello-cc/runtime.json`. A global Web
108
+ runtime does not make arbitrary directories managed; if the current project
109
+ has not run `hcc web`, `claude` and `codex` fall back to the real provider CLI
110
+ without creating `.hello-cc/mesh.db`.
107
111
  - `hcc down` stops only the web runtime. It does not kill tmux sessions.
108
112
  - Running `hcc web` again reattaches live recorded tmux panes.
109
113
 
@@ -118,11 +122,14 @@ hcc up
118
122
  ```
119
123
 
120
124
  Most users should start with `hcc web`.
125
+ `hcc web --local` is still Web mode; it binds the Web runtime only to
126
+ `127.0.0.1`.
121
127
 
122
128
  ## Project Boundary
123
129
 
124
- By default, the project boundary is the current working directory. If the
125
- current directory has no `.hello-cc/mesh.db`, hello-cc creates one on first use:
130
+ By default, the project boundary is the current working directory. Explicit
131
+ `hcc` commands such as `hcc web`, `hcc up`, `hcc task`, or `hcc peer start`
132
+ create the project database on first use:
126
133
 
127
134
  ```text
128
135
  /repo-a/.hello-cc/mesh.db
@@ -132,6 +139,11 @@ current directory has no `.hello-cc/mesh.db`, hello-cc creates one on first use:
132
139
  Those are two different projects by default. Sessions communicate naturally
133
140
  when they start from the same project path.
134
141
 
142
+ Provider shims are stricter: direct `claude` and `codex` commands only join
143
+ hello-cc when the current project already has a local `.hello-cc/runtime.json`
144
+ from `hcc web`. They do not use `~/.hello-cc/runtime.json` to auto-register or
145
+ create databases for unrelated directories.
146
+
135
147
  To share one bus across paths or worktrees, opt in explicitly:
136
148
 
137
149
  ```bash
@@ -95,6 +95,9 @@ codex resume <session-id>
95
95
 
96
96
  - 本地终端会 attach 到同一个 tmux 会话,仍然可以像普通 Claude/Codex 一样交互。
97
97
  - Web 控制台看到并操作的是同一个 tmux pane,不是浏览器里临时创建的假终端。
98
+ - shim 只使用当前项目的 `.hello-cc/runtime.json`。全局 Web runtime 不会让任意
99
+ 目录自动进入管理;如果当前项目没有运行过 `hcc web`,`claude` 和 `codex`
100
+ 会回退到真实 provider CLI,不会创建 `.hello-cc/mesh.db`。
98
101
  - `hcc down` 只停止 Web runtime,不会杀掉这些 tmux 会话。
99
102
  - 重新运行 `hcc web` 后,只要 tmux pane 还活着,Web 会重新接管。
100
103
 
@@ -108,10 +111,14 @@ hcc up
108
111
  ```
109
112
 
110
113
  普通用户优先使用 `hcc web`。
114
+ `hcc web --local` 仍然是 Web 模式,只是把 Web runtime 限制为监听
115
+ `127.0.0.1`。
111
116
 
112
117
  ## 项目边界
113
118
 
114
- 默认项目边界就是当前工作目录。如果当前目录没有 `.hello-cc/mesh.db`,hello-cc 会在首次使用时创建:
119
+ 默认项目边界就是当前工作目录。显式运行 `hcc web`、`hcc up`、`hcc task` 或
120
+ `hcc peer start` 这类 `hcc` 命令时,如果当前目录没有 `.hello-cc/mesh.db`,
121
+ hello-cc 会在首次使用时创建:
115
122
 
116
123
  ```text
117
124
  /repo-a/.hello-cc/mesh.db
@@ -120,6 +127,10 @@ hcc up
120
127
 
121
128
  这两个目录默认是两个不同项目,不会互相看到任务和消息。想让多个终端自然通信,就在同一个项目目录启动它们。
122
129
 
130
+ provider shim 更严格:直接运行 `claude` 和 `codex` 时,只有当前项目已经通过
131
+ `hcc web` 生成本地 `.hello-cc/runtime.json` 才会加入 hello-cc。它们不会使用
132
+ `~/.hello-cc/runtime.json` 去自动注册无关目录,也不会为这些目录创建数据库。
133
+
123
134
  如果确实要让多个路径或 worktree 共用一个总线,需要显式指定:
124
135
 
125
136
  ```bash
@@ -14,6 +14,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
14
14
  'HCC_NO_AUTO_INSTALL_TMUX',
15
15
  'HCC_PEER',
16
16
  'HCC_ROOT',
17
+ 'HCC_RUNTIME_LOCAL_ONLY',
17
18
  'HCC_SKIP_SHIM_INSTALL',
18
19
  PROVIDER_STATE_ENV,
19
20
  'HCC_SHIM_ENSURED',
@@ -38,6 +39,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
38
39
  export function childSessionEnv(extra = {}, baseEnv = process.env) {
39
40
  const env = { ...(baseEnv || {}), ...extra };
40
41
  delete env[WEB_CHILD_ENV];
42
+ delete env.HCC_RUNTIME_LOCAL_ONLY;
41
43
  return env;
42
44
  }
43
45
 
@@ -139,14 +139,22 @@ detect_current_hcc_pane() {
139
139
  return 1
140
140
  }
141
141
 
142
+ hcc_runtime_unavailable_start_failure() {
143
+ case "$1" in
144
+ *"No running web runtime found"*|*"No running hello-cc web runtime found"*|*"Runtime is not reachable"*|*"RUNTIME_NOT_RUNNING"*|*"RUNTIME_UNREACHABLE"*|*"RUNTIME_START_FAILED"*|*"RUNTIME_START_TIMEOUT"*)
145
+ return 0
146
+ ;;
147
+ esac
148
+ return 1
149
+ }
150
+
142
151
  if detect_current_hcc_pane; then
143
152
  SESSION_PEER_ID=$(printf '%s' "$CURRENT_HCC_SESSION" | sed 's/^hcc-[^-]*-//')
144
153
  if [ -n "$SESSION_PEER_ID" ] && [ "$SESSION_PEER_ID" != "$CURRENT_HCC_SESSION" ]; then
145
154
  PEER_ID="$SESSION_PEER_ID"
146
155
  fi
147
156
  export HCC_PEER="$PEER_ID"
148
- HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true
149
- "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
157
+ HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
150
158
  exec "$REAL_BIN" "$@"
151
159
  fi
152
160
 
@@ -155,12 +163,15 @@ HCC_PROVIDER_LAUNCH_SCRIPT='REAL_BIN="$1"; shift; HCC_TMUX_BIN="\${HCC_TMUX_BIN:
155
163
  set +e
156
164
  # Wrap in shell so pane_pid stays as bash; Ctrl+C drops back to bash
157
165
  START_OUT_FILE=$(mktemp "\${TMPDIR:-/tmp}/hcc-shim-start.XXXXXX")
158
- (HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true; "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
166
+ (HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
159
167
  START_STATUS=$?
160
168
  START_OUT=$(cat "$START_OUT_FILE" 2>/dev/null || true)
161
169
  rm -f "$START_OUT_FILE"
162
170
  set -e
163
171
  if [ "$START_STATUS" -ne 0 ]; then
172
+ if hcc_runtime_unavailable_start_failure "$START_OUT"; then
173
+ exec "$REAL_BIN" "$@"
174
+ fi
164
175
  printf '%s\\n' "$START_OUT" >&2
165
176
  exit "$START_STATUS"
166
177
  fi
@@ -352,14 +363,22 @@ detect_current_hcc_pane() {
352
363
  return 1
353
364
  }
354
365
 
366
+ hcc_runtime_unavailable_start_failure() {
367
+ case "$1" in
368
+ *"No running web runtime found"*|*"No running hello-cc web runtime found"*|*"Runtime is not reachable"*|*"RUNTIME_NOT_RUNNING"*|*"RUNTIME_UNREACHABLE"*|*"RUNTIME_START_FAILED"*|*"RUNTIME_START_TIMEOUT"*)
369
+ return 0
370
+ ;;
371
+ esac
372
+ return 1
373
+ }
374
+
355
375
  if detect_current_hcc_pane; then
356
376
  SESSION_PEER_ID=$(printf '%s' "$CURRENT_HCC_SESSION" | sed 's/^hcc-[^-]*-//')
357
377
  if [ -n "$SESSION_PEER_ID" ] && [ "$SESSION_PEER_ID" != "$CURRENT_HCC_SESSION" ]; then
358
378
  PEER_ID="$SESSION_PEER_ID"
359
379
  fi
360
380
  export HCC_PEER="$PEER_ID"
361
- HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true
362
- "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
381
+ HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
363
382
  exec "$REAL_BIN" "$@"
364
383
  fi
365
384
 
@@ -368,12 +387,15 @@ HCC_PROVIDER_LAUNCH_SCRIPT='REAL_BIN="$1"; shift; HCC_TMUX_BIN="\${HCC_TMUX_BIN:
368
387
  set +e
369
388
  # Wrap in shell so pane_pid stays as bash; Ctrl+C drops back to bash
370
389
  START_OUT_FILE=$(mktemp "\${TMPDIR:-/tmp}/hcc-shim-start.XXXXXX")
371
- (HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true; "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
390
+ (HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
372
391
  START_STATUS=$?
373
392
  START_OUT=$(cat "$START_OUT_FILE" 2>/dev/null || true)
374
393
  rm -f "$START_OUT_FILE"
375
394
  set -e
376
395
  if [ "$START_STATUS" -ne 0 ]; then
396
+ if hcc_runtime_unavailable_start_failure "$START_OUT"; then
397
+ exec "$REAL_BIN" "$@"
398
+ fi
377
399
  printf '%s\\n' "$START_OUT" >&2
378
400
  exit "$START_STATUS"
379
401
  fi
@@ -6,7 +6,7 @@ const DEFAULT_CLI_NAME = 'hcc';
6
6
 
7
7
  export async function runtimeRequest(ctx, method, route, body = null, runtime = null, opts = {}) {
8
8
  const cliName = opts.cliName || DEFAULT_CLI_NAME;
9
- const rt = runtime || readRuntime(ctx, { cliName });
9
+ const rt = runtime || readRuntime(ctx, { cliName, localOnly: Boolean(opts.localOnly) });
10
10
  const url = runtimeApiUrl(rt, route);
11
11
  const headers = { 'Content-Type': 'application/json' };
12
12
  headers['X-HCC-Root'] = ctx.root;
@@ -39,6 +39,7 @@ export function writeRuntime(ctx, runtime) {
39
39
  }
40
40
 
41
41
  export function readRuntime(ctx, opts = {}) {
42
+ const localOnly = Boolean(opts.localOnly || process.env.HCC_RUNTIME_LOCAL_ONLY === '1');
42
43
  if (process.env.HCC_RUNTIME_URL) {
43
44
  return {
44
45
  base_url: process.env.HCC_RUNTIME_URL,
@@ -56,9 +57,11 @@ export function readRuntime(ctx, opts = {}) {
56
57
  try { fs.rmSync(file, { force: true }); } catch {}
57
58
  }
58
59
  }
59
- const global = readGlobalRuntimeFile();
60
- if (global?.base_url) {
61
- return { ...global, source: globalRuntimePath(), global: true };
60
+ if (!localOnly) {
61
+ const global = readGlobalRuntimeFile();
62
+ if (global?.base_url) {
63
+ return { ...global, source: globalRuntimePath(), global: true };
64
+ }
62
65
  }
63
66
  const productName = opts.productName || DEFAULT_PRODUCT_NAME;
64
67
  const cliName = opts.cliName || DEFAULT_CLI_NAME;
package/lib/web/http.mjs CHANGED
@@ -19,7 +19,14 @@ function readRequestBody(req, limit = 1024 * 1024) {
19
19
  export async function readJsonRequest(req) {
20
20
  const body = await readRequestBody(req);
21
21
  if (!body.trim()) return {};
22
- return JSON.parse(body);
22
+ try {
23
+ return JSON.parse(body);
24
+ } catch (err) {
25
+ if (err instanceof SyntaxError) {
26
+ throw new CliError('BAD_REQUEST', 'Invalid JSON request body');
27
+ }
28
+ throw err;
29
+ }
23
30
  }
24
31
 
25
32
  export function sendHttp(res, status, contentType, body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1216,6 +1216,197 @@ function libModuleFiles() {
1216
1216
  .map((name) => path.join('lib', name));
1217
1217
  }
1218
1218
 
1219
+ function assertShimRuntimeUnavailableFallback(generateShim) {
1220
+ const fallbackDir = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-shim-fallback-${testId}-`));
1221
+ try {
1222
+ const fakeHcc = path.join(fallbackDir, 'hcc');
1223
+ fs.writeFileSync(fakeHcc, `#!/usr/bin/env bash
1224
+ case "\${1:-}" in
1225
+ shim)
1226
+ exit 64
1227
+ ;;
1228
+ find-root)
1229
+ if [ -n "\${HCC_FAKE_ROOT:-}" ]; then
1230
+ printf '%s\\n' "$HCC_FAKE_ROOT"
1231
+ exit 0
1232
+ fi
1233
+ exit 1
1234
+ ;;
1235
+ web)
1236
+ exit 1
1237
+ ;;
1238
+ peer)
1239
+ if [ "\${2:-}" = "start" ]; then
1240
+ printf '%s\\n' "hcc: No running web runtime found. Start hcc web first, then hcc peer start \${3:-peer}" >&2
1241
+ exit 1
1242
+ fi
1243
+ exit 1
1244
+ ;;
1245
+ esac
1246
+ exit 1
1247
+ `, { mode: 0o755 });
1248
+
1249
+ const cases = [
1250
+ {
1251
+ tool: { name: 'claude', kind: 'claude', resumeFlag: '--resume' },
1252
+ args: ['--resume', 'missing-web-session'],
1253
+ expected: 'real-claude --resume missing-web-session'
1254
+ },
1255
+ {
1256
+ tool: { name: 'codex', kind: 'codex' },
1257
+ args: ['resume', 'missing-web-session'],
1258
+ expected: 'real-codex resume missing-web-session'
1259
+ }
1260
+ ];
1261
+
1262
+ for (const entry of cases) {
1263
+ const realBin = path.join(fallbackDir, `real-${entry.tool.name}`);
1264
+ const shimBin = path.join(fallbackDir, entry.tool.name);
1265
+ const logFile = path.join(fallbackDir, `${entry.tool.name}.log`);
1266
+ fs.writeFileSync(realBin, `#!/usr/bin/env bash
1267
+ printf 'real-${entry.tool.name}'
1268
+ for arg in "$@"; do printf ' %s' "$arg"; done
1269
+ printf '\\n'
1270
+ if [ -n "\${HCC_FAKE_LOG:-}" ]; then
1271
+ printf 'real-${entry.tool.name}' >> "$HCC_FAKE_LOG"
1272
+ for arg in "$@"; do printf ' %s' "$arg" >> "$HCC_FAKE_LOG"; done
1273
+ printf '\\n' >> "$HCC_FAKE_LOG"
1274
+ fi
1275
+ `, { mode: 0o755 });
1276
+ fs.writeFileSync(shimBin, generateShim(fakeHcc, realBin, entry.tool), { mode: 0o755 });
1277
+
1278
+ const result = runMaybe(shimBin, entry.args, {
1279
+ cwd: fallbackDir,
1280
+ env: {
1281
+ ...env,
1282
+ HCC_FAKE_ROOT: fallbackDir,
1283
+ HCC_FAKE_LOG: logFile
1284
+ }
1285
+ });
1286
+ if (result.status !== 0) {
1287
+ fail(`${entry.tool.name} shim did not fall back when runtime was unavailable:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1288
+ }
1289
+ if (!String(result.stdout || '').includes(entry.expected)) {
1290
+ fail(`${entry.tool.name} shim fallback did not launch the real provider:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1291
+ }
1292
+ const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
1293
+ if (!logOutput.includes(entry.expected)) {
1294
+ fail(`${entry.tool.name} shim fallback did not preserve provider argv:\n${logOutput}`);
1295
+ }
1296
+ if (String(result.stderr || '').includes('No running web runtime found')) {
1297
+ fail(`${entry.tool.name} shim leaked hcc runtime failure instead of transparent fallback:\n${result.stderr}`);
1298
+ }
1299
+ }
1300
+ } finally {
1301
+ try { fs.rmSync(fallbackDir, { recursive: true, force: true }); } catch {}
1302
+ }
1303
+ }
1304
+
1305
+ async function assertShimIgnoresGlobalRuntime(generateShim) {
1306
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-shim-global-runtime-${testId}-`));
1307
+ const fakeHome = path.join(testDir, 'home');
1308
+ const projectRoot = path.join(testDir, 'project-without-runtime');
1309
+ const requests = [];
1310
+ let server = null;
1311
+ try {
1312
+ fs.mkdirSync(path.join(fakeHome, '.hello-cc'), { recursive: true });
1313
+ fs.mkdirSync(projectRoot, { recursive: true });
1314
+
1315
+ server = http.createServer((req, res) => {
1316
+ requests.push({ method: req.method, url: req.url });
1317
+ res.setHeader('Content-Type', 'application/json');
1318
+ if (req.method === 'GET' && req.url.startsWith('/api/runtime')) {
1319
+ res.end(JSON.stringify({ ok: true }));
1320
+ return;
1321
+ }
1322
+ if (req.method === 'POST' && req.url.startsWith('/api/sessions')) {
1323
+ res.end(JSON.stringify({ session: {
1324
+ id: 'global-runtime-session',
1325
+ kind: 'claude',
1326
+ role: 'peer',
1327
+ status: 'running',
1328
+ pane: '%900'
1329
+ } }));
1330
+ return;
1331
+ }
1332
+ res.statusCode = 404;
1333
+ res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));
1334
+ });
1335
+ await new Promise((resolve, reject) => {
1336
+ server.once('error', reject);
1337
+ server.listen(0, '127.0.0.1', resolve);
1338
+ });
1339
+ const address = server.address();
1340
+ const globalRuntime = {
1341
+ product: 'hello-cc',
1342
+ version: 'regression',
1343
+ pid: process.pid,
1344
+ root: testDir,
1345
+ db: path.join(testDir, '.hello-cc', 'mesh.db'),
1346
+ host: '127.0.0.1',
1347
+ port: address.port,
1348
+ base_url: `http://127.0.0.1:${address.port}`,
1349
+ token: '',
1350
+ started_at: Math.floor(Date.now() / 1000),
1351
+ global_runtime: true
1352
+ };
1353
+ fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2));
1354
+
1355
+ const hccWrapper = path.join(testDir, 'hcc-wrapper');
1356
+ fs.writeFileSync(hccWrapper, `#!/usr/bin/env bash\nexec ${sh(process.execPath)} ${sh(hccBin)} "$@"\n`, { mode: 0o755 });
1357
+
1358
+ const cases = [
1359
+ {
1360
+ tool: { name: 'claude', kind: 'claude', resumeFlag: '--resume' },
1361
+ args: ['--resume', 'global-runtime-session'],
1362
+ expected: 'real-claude --resume global-runtime-session'
1363
+ },
1364
+ {
1365
+ tool: { name: 'codex', kind: 'codex' },
1366
+ args: ['resume', 'global-runtime-session'],
1367
+ expected: 'real-codex resume global-runtime-session'
1368
+ }
1369
+ ];
1370
+
1371
+ for (const entry of cases) {
1372
+ requests.length = 0;
1373
+ fs.rmSync(path.join(projectRoot, '.hello-cc'), { recursive: true, force: true });
1374
+ const realBin = path.join(testDir, `real-${entry.tool.name}`);
1375
+ const shimBin = path.join(testDir, entry.tool.name);
1376
+ fs.writeFileSync(realBin, `#!/usr/bin/env bash
1377
+ printf 'real-${entry.tool.name}'
1378
+ for arg in "$@"; do printf ' %s' "$arg"; done
1379
+ printf '\\n'
1380
+ `, { mode: 0o755 });
1381
+ fs.writeFileSync(shimBin, generateShim(hccWrapper, realBin, entry.tool), { mode: 0o755 });
1382
+ const result = runMaybe(shimBin, entry.args, {
1383
+ cwd: projectRoot,
1384
+ env: {
1385
+ ...env,
1386
+ HOME: fakeHome,
1387
+ HCC_SHIM_ENSURED: '1',
1388
+ HCC_SHIM_NO_ATTACH: '1'
1389
+ }
1390
+ });
1391
+ if (result.status !== 0) {
1392
+ fail(`${entry.tool.name} shim failed while ignoring global runtime:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1393
+ }
1394
+ if (!String(result.stdout || '').includes(entry.expected)) {
1395
+ fail(`${entry.tool.name} shim used global runtime instead of falling back:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
1396
+ }
1397
+ if (requests.length) {
1398
+ fail(`${entry.tool.name} shim contacted global runtime despite project-local restriction:\n${JSON.stringify(requests, null, 2)}`);
1399
+ }
1400
+ if (fs.existsSync(path.join(projectRoot, '.hello-cc', 'mesh.db'))) {
1401
+ fail(`${entry.tool.name} shim created a project database while only a global runtime existed`);
1402
+ }
1403
+ }
1404
+ } finally {
1405
+ if (server?.listening) await new Promise((resolve) => server.close(resolve));
1406
+ try { fs.rmSync(testDir, { recursive: true, force: true }); } catch {}
1407
+ }
1408
+ }
1409
+
1219
1410
  async function expectBoundedTmuxStream(label) {
1220
1411
  let nodes = [];
1221
1412
  await waitFor(() => {
@@ -1260,6 +1451,38 @@ function startRuntime(options = {}) {
1260
1451
  if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
1261
1452
  }
1262
1453
 
1454
+ async function assertWebWrapperParentSurvives() {
1455
+ const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
1456
+ const wrapperPort = port + 101;
1457
+ let wrapperPid = null;
1458
+ try {
1459
+ const script = [
1460
+ 'set -e',
1461
+ `out=$(${sh(process.execPath)} ${sh(hccBin)} --root ${sh(wrapperRoot)} web --local --port ${wrapperPort} --no-discover --no-guidance)`,
1462
+ `printf '%s\\n' "$out"`
1463
+ ].join('\n');
1464
+ const result = runMaybe('bash', ['-lc', script], { env });
1465
+ if (result.status !== 0) {
1466
+ fail(`hcc web killed or failed inside a wrapper shell:\nstdout=${result.stdout}\nstderr=${result.stderr}\nstatus=${result.status}\nsignal=${result.signal}`);
1467
+ }
1468
+ const match = String(result.stdout || '').match(/^pid:\s*(\d+)/m);
1469
+ if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
1470
+ wrapperPid = Number.parseInt(match[1], 10);
1471
+ ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
1472
+ } finally {
1473
+ runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
1474
+ if (wrapperPid) {
1475
+ try {
1476
+ await waitForProcessExit(wrapperPid, 'wrapper runtime process exit', 5000);
1477
+ } catch {
1478
+ try { process.kill(wrapperPid, 'SIGTERM'); } catch {}
1479
+ try { await waitForProcessExit(wrapperPid, 'wrapper runtime process exit after SIGTERM', 2000); } catch {}
1480
+ }
1481
+ }
1482
+ try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
1483
+ }
1484
+ }
1485
+
1263
1486
  async function stopRuntime() {
1264
1487
  if (!runtimePid) return;
1265
1488
  const pid = runtimePid;
@@ -1420,6 +1643,7 @@ async function setupRegression() {
1420
1643
  fail(`shim ensure preserved stale real binary instead of rediscovering PATH binary:\n${rediscoveredShim}`);
1421
1644
  }
1422
1645
  await stopRuntime();
1646
+ await assertWebWrapperParentSurvives();
1423
1647
  assertPeerBindingUniqueConstraints();
1424
1648
  assertLegacySchemaMigration();
1425
1649
  assertRegisteredProjectDbMigration();
@@ -1442,6 +1666,18 @@ async function setupRegression() {
1442
1666
  if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
1443
1667
  const tokenResponse = await runtimeFetch('/api/runtime');
1444
1668
  if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
1669
+ const badJsonResponse = await runtimeFetch('/api/projects', {
1670
+ method: 'POST',
1671
+ headers: { 'Content-Type': 'application/json' },
1672
+ body: '{bad json'
1673
+ });
1674
+ if (badJsonResponse.status !== 400) {
1675
+ fail(`bad JSON request returned ${badJsonResponse.status}, expected 400:\n${await badJsonResponse.text()}`);
1676
+ }
1677
+ const badJsonBody = await badJsonResponse.json();
1678
+ if (badJsonBody?.error?.code !== 'BAD_REQUEST') {
1679
+ fail(`bad JSON request did not return BAD_REQUEST:\n${JSON.stringify(badJsonBody, null, 2)}`);
1680
+ }
1445
1681
  const tokenFile = path.join(home, '.hello-cc', 'web-token');
1446
1682
  ensureFile(tokenFile, tokenRuntime.token);
1447
1683
  fs.rmSync(tokenFile, { force: true });
@@ -2251,6 +2487,10 @@ async function multiProjectWebWorkflow() {
2251
2487
  if (!managedActionSession.action_token) {
2252
2488
  fail(`managed web session did not include action token:\n${JSON.stringify(managedActionSession, null, 2)}`);
2253
2489
  }
2490
+ const runtimeActionToken = currentRuntime().token || '';
2491
+ if (managedActionSession.action_token === runtimeActionToken || managedActionSession.action_token.length < 32) {
2492
+ fail(`managed web session action token is not an independent session token:\n${JSON.stringify({ runtimeActionToken, managedActionSession }, null, 2)}`);
2493
+ }
2254
2494
  const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
2255
2495
  method: 'POST',
2256
2496
  headers: { 'Content-Type': 'application/json' },
@@ -2537,6 +2777,10 @@ async function shimTmuxWorkflow() {
2537
2777
  }
2538
2778
 
2539
2779
  log('[6/13] shim tmux-backed launch');
2780
+ if (!fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) {
2781
+ startRuntime();
2782
+ }
2783
+ await waitRuntime();
2540
2784
  const shim = path.join(home, '.hcc-shims', 'claude');
2541
2785
  const codexShim = path.join(home, '.hcc-shims', 'codex');
2542
2786
  const claudeVersion = run(shim, ['--version'], { cwd: root, env });
@@ -2608,8 +2852,8 @@ async function shimTmuxWorkflow() {
2608
2852
  hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
2609
2853
  await waitForFile(firstEnvFile, 'shim-first', 'shim first env');
2610
2854
  const shimInternalEnvFile = path.join(outDir, 'shim-internal-env');
2611
- hcc(['inject', peer, `printf '%s|%s\\n' "\${HCC_SHIM_ENSURED-unset}" "\${HCC_SKIP_SHIM_INSTALL-unset}" > ${shimInternalEnvFile}`]);
2612
- await waitForFile(shimInternalEnvFile, 'unset|unset', 'shim internal env cleanup');
2855
+ hcc(['inject', peer, `printf '%s|%s|%s\\n' "\${HCC_SHIM_ENSURED-unset}" "\${HCC_SKIP_SHIM_INSTALL-unset}" "\${HCC_RUNTIME_LOCAL_ONLY-unset}" > ${shimInternalEnvFile}`]);
2856
+ await waitForFile(shimInternalEnvFile, 'unset|unset|unset', 'shim internal env cleanup');
2613
2857
 
2614
2858
  const restarted = run(shim, ['--resume', 'shim-regression-session'], {
2615
2859
  cwd: root,
@@ -3339,9 +3583,13 @@ async function syntaxAndHelp() {
3339
3583
  if (!integrationShimScriptSource.includes('function generateShim') ||
3340
3584
  !integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
3341
3585
  !integrationShimScriptSource.includes('HCC_SHIM_ENSURED') ||
3342
- !integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL')) {
3586
+ !integrationShimScriptSource.includes('HCC_RUNTIME_LOCAL_ONLY') ||
3587
+ !integrationShimScriptSource.includes('hcc_runtime_unavailable_start_failure')) {
3343
3588
  fail('integrations shim script module is missing expected generateShim wiring');
3344
3589
  }
3590
+ if (integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web')) {
3591
+ fail('integration shim script still auto-starts hcc web instead of falling back to the real provider');
3592
+ }
3345
3593
  if (!shellPathSource.includes('function installPathEntry') ||
3346
3594
  !shellPathSource.includes('function uninstallPathEntry') ||
3347
3595
  !shellPathSource.includes('.hcc-shims')) {
@@ -3374,6 +3622,8 @@ async function syntaxAndHelp() {
3374
3622
  if (setupModule.SHIM_DIR !== integrationShims.SHIM_DIR) fail('setup shim dir export mismatch');
3375
3623
  if (typeof integrationShimScript.generateShim !== 'function') fail('integrations shim script module missing generateShim export');
3376
3624
  if (shimScriptCompat.generateShim !== integrationShimScript.generateShim) fail('shim script compatibility export mismatch');
3625
+ assertShimRuntimeUnavailableFallback(integrationShimScript.generateShim);
3626
+ await assertShimIgnoresGlobalRuntime(integrationShimScript.generateShim);
3377
3627
  for (const name of ['installPathEntry', 'uninstallPathEntry']) {
3378
3628
  if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
3379
3629
  if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);