Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,109 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.128.0] - 2026-09-08

### Fixed — starting an IDE session now brings up that project's UI

Opening a session in a project left you with no UI "in some cases", and the
cases were not random: **nothing in C3 connected a session starting to a UI
server running.** `ProjectManager.launch_session` had five callers — two hub
endpoints, the per-project server, the TUI, and a thread in `run_hub` that
fired ONCE, two seconds after hub startup, over projects with
`autostart_ui=true` (true on 0 of the 60 projects registered on the machine
this was found on). So when a UI *was* there it was a detached `pythonw`
server a previous session had left behind — survival, not startup. Reboot,
`taskkill`, or the hub's Stop button, and the next session had nothing.

- **`cli/hook_session_open.ensure_ui(project, host_session_id)`** — the
SessionStart hook now launches the project's UI when one is not already
running. Guards, in order: `C3_NO_UI_AUTOSTART` / `C3_BENCHMARK_MODE`,
`.c3/config.json → {"ui": {"autostart_on_session": false}}`, the project
being registered with the hub at all (a scratch clone or a test fixture
gets nothing), and a live registry port for this project — which is what
keeps `resume` and `clear`, both real SessionStart events, from stacking a
second server. `source: compact` returns before any of it. Failures go to
`.c3/hook_errors.log`; the hook still prints nothing, because SessionStart
stdout becomes model context.
- **`cli/hub_server.run_hub`** — the one-shot autostart thread is now a 60 s
sweep (`_UI_SWEEP_INTERVAL_S`) that reaps orphans and chases autostart
projects with a live session, so a hub started *after* the IDE, or a UI that
died mid-session, is no longer a dead end. The first pass keeps the old
contract ("autostart launches the UI when the hub starts").
- **Ownership** — `cli/server._register_session` records `pid`, `owner`
(`session` when a session launched it, else `user`, from `C3_UI_OWNER`) and
`owner_session`. `ProjectManager.sweep_registry` stops a **session**-owned
server once its project has no heartbeat left and its activity log has been
untouched for `ui_reap_minutes` (new `~/.c3/hub_config.json` key, default
30, `0` disables) — and returns `{"dropped", "reaped"}`. A server a human
started with `c3 ui` or Open UI is never touched. This is what stops the
orphan collection: the registry on the machine above held a UI server from
a session that had ended 24 hours earlier.

### Fixed — session liveness is a fact now, not an inference

`ProjectManager._get_live_session_info` read the newest `session_start` row
and ended it only on a `session_save` — written solely when a human clicks end
in the hub — or after 20 minutes of tool-call silence. Both halves were wrong
in the ordinary case: a closed IDE read "live" for up to 20 minutes (the
`session_end` row added in 2.126.0 was read by NOTHING), an open-but-quiet
session read dead while its MCP process sat there serving, and two sessions on
one project collapsed into one.

- **`services/session_live.py`** (new) — one heartbeat file per session,
`.c3/live/<session_id>.json` = `{session_id, host_session_id, ide, pid, ts}`,
written through `services/atomic_json.write_json_atomic` (the Windows-safe
publish; a pid-only temp suffix races across threads). `beat()`, `clear()`,
`live_sessions(ttl=180)`, `is_live()`. Liveness is decided by the timestamp,
never by probing the pid: pids are reused across reboots and there is no
cheap portable "is this pid alive" (C3 has no psutil dependency).
`HEARTBEAT_INTERVAL_S = 60`, `HEARTBEAT_TTL_S = 180` — three missed beats.
A session id is sanitised before it becomes a filename.
- **`cli/mcp_server.lifespan`** — beats once right after `session_start`, then
on a `c3-session-heartbeat` daemon thread; the `finally` block stops the
thread and removes the file *before* the slow teardown steps, so a reader
polling during shutdown already sees the session as gone.
- **`ProjectManager`** — `_get_live_sessions(path)` returns EVERY live session,
newest first: heartbeats first (authoritative), then the old activity-log
inference for a session with no heartbeat (a pre-2.128 C3 in another
checkout, or a process killed without cleanup). The inference now treats a
`session_end` row as terminal as well, matching on `session_id` **or**
`host_session_id` (the hook writes `session_id: ""` when no link file
exists yet). `_get_live_session_info` is kept as "the newest one".
`list_projects` gains `sessions` and `session_count`; every singular field
(`session_active`, `live_session_id`, `started_at`, …) still describes the
newest session, so existing consumers are unaffected.
- **Hub card** — `MCP×N` when a project has more than one live session (two
IDEs, or a repo and its worktrees).

### Fixed — hooks added to the installer now reach installed projects

The 2.126.0 `SessionStart` / `SessionEnd` hooks were registered by
`c3 install-mcp` only, so no project installed before that release ever wrote
a `session_open` or `session_end` row — 60 projects here, C3's own repo among
them, which is why the end of a session had no signal to read.
`cli/hub_server._migrate_project_hooks` already rewrote every registered
project's settings at hub startup, but for exactly one hardcoded entry
(`hook_c3read` on `PostToolUse`).

- **`c3_hook_migrations()` + `migrate_hooks_for_project(path)`** — the
migration is now a table of `(settings file, event, "is C3's entry already
here", entry)` rows, with the lifecycle hooks added for Claude (Gemini has
no session-lifecycle event). Additive and idempotent: a user's own
`SessionStart` hook survives, a second run writes nothing, an unparseable
settings file is left alone, and a settings file that does not exist is
never created — the hub is not the place to decide a project should be
installed for an IDE. Future hooks cost one row.
- **The migration's commands were unrunnable on Windows** — it quoted paths
with `shlex.quote`, which on Windows produces a SINGLE-quoted backslash
path. cmd.exe does not treat single quotes as quoting, so every entry it
wrote for a path containing a space (every path under `1. Projects`, say)
was dead on arrival — the `hook_c3read` entry it has been writing since
2.21 included. The installer learned this on 2026-07-26 and has used a
double-quoted forward-slash path ever since; that logic now lives in
`cli/_hook_utils.hook_command_arg` and both writers call it. A test pins
the migration's command string against the installer's, byte for byte.

## [2.127.0] - 2026-09-06

### Added — override cost data: "this rule is costing you" (C3 Desk, D3a)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ c3 ui # binds the first free port from 3333

Twelve tabs. The dashboard above shows token savings, indexed files, the live session, and a stream of recent tool calls and file changes.

Since v2.128.0 you rarely start it by hand: opening an IDE session in a registered project brings its UI up, and a session proves it is alive with a heartbeat rather than with tool-call traffic — so a quiet session stays live and a closed one disappears at once. Servers a session started are reaped once the session is gone; the ones you started are not. → [Session liveness](https://github.com/drknowhow/code-context-control/blob/main/docs/session-liveness.md)

| Tab | What it's for |
|---|---|
| **Dashboard** | Token savings, codebase breakdown, live session counters, recent activity |
Expand Down
22 changes: 22 additions & 0 deletions cli/_hook_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import os
import re
import shlex
import sys
import traceback
from datetime import datetime, timezone
Expand All @@ -34,6 +35,27 @@
STATE_WARNINGS: list = []


def hook_command_arg(raw) -> str:
"""Quote one argv element for a hook ``command`` string in settings.

Windows: a DOUBLE-quoted forward-slash path. Claude Code runs hooks through
Git Bash, where ``shlex.quote`` produces SINGLE quotes around a backslash
path — cmd.exe does not treat single quotes as quoting, so any path with a
space or parentheses (``Claude Code Companion (C3)``) breaks. A
double-quoted forward-slash path is parsed correctly by bash AND cmd.
Verified 2026-07-26 under Git Bash on Windows 11; the installer in
cli/c3.py carries the full investigation, including why no ``cmd.exe /c``
wrapper belongs here.

Anything that writes a hook command — the installer and the hub's
migration — must go through this, or the entry it writes is dead on
Windows.
"""
if sys.platform == "win32":
return '"' + str(raw).replace("\\", "/") + '"'
return shlex.quote(str(raw))


def ensure_utf8_stdio() -> None:
"""Force UTF-8 on the hook process's stdio pipes.

Expand Down
11 changes: 5 additions & 6 deletions cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
import logging
import os
import re
import shlex
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -92,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.127.0"
__version__ = "2.128.0"


def _compress_file_cli(compressor, path, mode="smart", **kw):
Expand Down Expand Up @@ -5542,10 +5541,10 @@ def cmd_install_mcp(args):
# the wrapper was never needed. A double-quoted forward-slash path is parsed
# correctly by bash AND cmd, including paths containing spaces or parentheses
# (e.g. "Claude Code Companion (C3)"), which is why the wrapper was added.
def _hook_arg(raw: str) -> str:
if sys.platform == "win32":
return '"' + str(raw).replace("\\", "/") + '"'
return shlex.quote(str(raw))
# Lives in cli/_hook_utils so the hub's hook migration quotes commands
# the same way — it used shlex.quote and wrote entries that Windows
# could not run.
from cli._hook_utils import hook_command_arg as _hook_arg

# v2.42: single dispatcher script per hook event instead of N separate
# per-hook commands. One interpreter spawn per event; the dispatcher
Expand Down
104 changes: 103 additions & 1 deletion cli/hook_session_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
- a ``session`` notification (``kind="session"``, ``ref_id=host id``,
``info``, title ``Session started <short id>``).

It also brings up the project's UI server when one is not already running
(``ensure_ui`` below, v2.128.0) — the only place in C3 where "a session
started" causes "the UI is up". It needs the project to be registered with
the hub, and it skips a project that already has a live UI port, so a
``resume`` or ``clear`` never stacks a second server. Opt out with
``C3_NO_UI_AUTOSTART=1`` or
``.c3/config.json → {"ui": {"autostart_on_session": false}}``.

``source: "compact"`` writes NOTHING: a compaction is the same session and
the same MCP process, and a "Session started" toast on every compaction
would teach the user to ignore the real ones.
Expand All @@ -37,6 +45,8 @@
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

Expand All @@ -48,6 +58,98 @@
_SILENT_SOURCES = {"compact"}


def _ui_autostart_enabled(project: Path) -> bool:
"""Opt-outs, in the order a user reaches for them."""
if os.environ.get("C3_NO_UI_AUTOSTART") or os.environ.get("C3_BENCHMARK_MODE"):
return False
try:
cfg = json.loads((project / ".c3" / "config.json").read_text(encoding="utf-8"))
except Exception:
return True # no readable config is not a reason to withhold the UI
ui = cfg.get("ui")
if isinstance(ui, dict) and "autostart_on_session" in ui:
return bool(ui.get("autostart_on_session"))
return True


def _is_registered(pm, project: Path) -> bool:
"""True when the hub knows this project.

The UI server is a hub-level surface: a directory the user never
registered gets nothing automatic (``c3 ui`` still works). This is also
what keeps a test fixture or a scratch clone from growing a web server.
"""
target = os.path.normcase(str(project))
for entry in pm._read_projects():
raw = str(entry.get("path") or "")
if raw and os.path.normcase(raw) == target:
return True
try:
if raw and os.path.normcase(str(Path(raw).resolve())) == target:
return True
except Exception:
continue
return False


def _ui_running(pm, project: Path) -> bool:
"""True when a live UI server is already registered for this project.

This is what keeps `resume` / `clear` — both real SessionStart events —
from stacking a second server on every reconnect.
"""
target = os.path.normcase(str(project))
for entry in pm._read_registry():
raw = str(entry.get("project_path") or "")
if not raw:
continue
try:
same = os.path.normcase(str(Path(raw).resolve())) == target
except Exception:
same = os.path.normcase(raw) == target
if same and entry.get("port") and pm._port_alive(entry["port"]):
return True
return False


def ensure_ui(project: Path, host_session_id: str = "") -> str:
"""Bring up this project's UI server for the session that just opened.

Nothing else in C3 connected "a session started" to "the UI is running":
the hub's sweep only chases projects flagged `autostart_ui`, and before
2.128.0 it ran once at hub startup — so a UI was only ever there because
an earlier session had left one behind.

Returns a short reason string (tests and diagnostics read it). Never
raises and never prints — SessionStart stdout becomes model context.
"""
try:
project = Path(project).resolve()
except Exception:
project = Path(project)
if not _ui_autostart_enabled(project):
return "disabled"
try:
from services.project_manager import ProjectManager
pm = ProjectManager()
if not _is_registered(pm, project):
return "unregistered"
if _ui_running(pm, project):
return "already-running"
# launch_session copies os.environ into the detached child, which is
# where cli/server._register_session reads the ownership from.
os.environ["C3_UI_OWNER"] = "session"
if host_session_id:
os.environ["C3_UI_OWNER_SESSION"] = host_session_id
result = pm.launch_session(str(project))
if result.get("launched"):
return "launched"
return f"failed: {result.get('error', '')}".strip()
except Exception as exc:
log_hook_error("hook_session_open", exc)
return "error"


def run(payload: dict, project_path: Path | None = None):
"""Core logic — importable by the dispatcher and tests. Returns None."""
start_source = str(payload.get("source") or "").strip().lower()
Expand Down Expand Up @@ -76,11 +178,11 @@ def run(payload: dict, project_path: Path | None = None):
kind="session", ref_id=host_sid)
except Exception:
pass
ensure_ui(project, host_sid)
return None


def main() -> None:
import json
try:
data = json.load(sys.stdin)
except Exception as exc:
Expand Down
Loading
Loading