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
5 changes: 4 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ A process a Session spawns — a subagent, a review crew. It appears in the rost
_Avoid_: child Session, subagent (the agent's mechanism word), crew

**Session Name**:
What the user and the system call one Session — the agent's own name for it.
What the user and the system call one Session: `<project> · <task>`, where the project is the
Git repository the Session is working in — its own directory when it is in none — and the task
is the agent's own name for the Session. Composed by the lane that first saw the Session and
never changed after that, because the user says it back to address the Session.
_Avoid_: label, title

**Relay**:
Expand Down
66 changes: 66 additions & 0 deletions src/gpt_voicecoding/adapters/agent/_naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""How both lanes turn a project and a task into one Session Name.

**Pure, and shared.** Two lanes compose the same shape from different facts —
Claude's official roster `name`, Codex's daemon `Thread.name` — and a rule that
lived twice would drift twice. Nothing here touches the filesystem, a process or
a clock: the caller resolves the project half (`_project.py`) and hands it in,
which is what keeps this testable against strings rather than against whichever
repositories happen to be on the machine.

**The task half is validated the way the reference implementation validated it**
(`legacy@1d32845:bridge/labels.py:97-106`, *adapted*): non-empty after stripping,
and one line. Legacy *raised* `SessionLabelError` on both — and could, because
its caller was a single self-report ingress handling one Session that had just
spoken. This caller is a discovery pass over every row on the machine every few
seconds, where one bad string must never cost an enumeration: an unnameable row
is one row without a name, not a lane that failed. So the answer is `None` —
"this Session has no name yet", the state the roster already renders
(`core/sessions.py`) — and the reason is logged, so a name that never appears is
findable rather than silent. (#78 writes the signature as returning a name;
amended by advisor ruling, 2026-08-27.)

**What is deliberately not here.** No transcript-derived task, no `ai-title`,
no mutable history: legacy measured that route at 30% of Sessions never writing
one and the rest writing one a median of 440 s late
(`legacy@1d32845:bridge/labels.py:73-84`, *dropped, because* a record a product
writes when it feels like it cannot carry a name the user speaks with). A name
composed here is composed from a fact the lane already had.
"""

from __future__ import annotations

import logging

from gpt_voicecoding.seams.identity import NAME_SEPARATOR, SessionName

_log = logging.getLogger(__name__)


def compose(project_name: str, task: str) -> SessionName | None:
"""``<project> · <task>``, or `None` when the parts cannot make one.

Both halves are stripped first, because the sources are other people's
strings: a roster field and a daemon field, either of which may arrive
padded. A task that is empty or spans more than one line is refused rather
than flattened — a Session Name is spoken and typed after `@`, so a name
carrying a newline would be a name the user cannot say back.

The separator is refused inside either half by `SessionName` itself, and
that refusal is made here for the same reason as the rest: a Codex thread
the user named `a · b` is a Session that has no name, not a lane that broke.
"""
project = project_name.strip()
wanted = task.strip()
if not project or not wanted:
return _unnamed(project_name, task, "a half with no words in it")
if len(wanted.splitlines()) > 1:
return _unnamed(project_name, task, "a task spanning more than one line")
if NAME_SEPARATOR.strip() in project or NAME_SEPARATOR.strip() in wanted:
return _unnamed(project_name, task, f"a half carrying {NAME_SEPARATOR.strip()!r}")
return SessionName(project=project, task=wanted)


def _unnamed(project_name: str, task: str, reason: str) -> None:
"""Say why this Session has no name, and answer that it has none."""
_log.info("no Session Name for %r · %r: %s", project_name, task, reason)
return None
136 changes: 136 additions & 0 deletions src/gpt_voicecoding/adapters/agent/_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""What project a Session is working on, read from its workspace.

The project half of every Session Name (`_naming.py`), and the **one place**
either lane runs a command to get it — a rule worth a module of its own, because
this is asked once per row per discovery tick and a second copy of it in the
other lane would be a second subprocess per row per tick.

**Git first, the directory second.** `git rev-parse --git-common-dir` is what the
reference implementation asked (`legacy@1d32845:bridge/labels.py:28-70`), and it
is the right question: it answers with the *repository's* directory from any
worktree or subdirectory, so two Sessions in `repo/` and `repo/src/` are working
on one project and are named for it. Legacy then raised when the answer was not
a repository at all, and legacy's label never existed. That is **adapted** here: v1.0
bridges every Session the user starts, so one in `~/scratch` is named
`scratch · <task>` from the directory's own basename rather than left unnamed
and unspeakable. The basename is a fact about the Session, not a guess.

**Cached per resolved workspace, for the life of the adapter.** A workspace does
not change which repository it belongs to while a Session runs in it, and the
cadence would otherwise pay a subprocess per Session every five seconds. The
cache is keyed on the realpath, so the same repository reached by two paths is
one entry. **The basename fallback is remembered too**, deliberately: a `git`
that was busy the first time a workspace was seen would otherwise be asked again
on every tick for a Session whose name the registry has already frozen, and the
second answer could never reach the roster anyway.

**A failure is a directory name, never an exception.** Discovery is answering
"what is running"; `git` being absent, slow or angry is not a reason to lose a
row, and the basename is standing by.
"""

from __future__ import annotations

import asyncio
import logging
import os
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Final

_log = logging.getLogger(__name__)

#: The command, and the flags that make its answer one absolute path. Asked of
#: the Session's own workspace, so the answer is about the Session's repository
#: and not about wherever this engine happens to be running.
GIT_COMMAND: Final = ("git", "rev-parse", "--path-format=absolute", "--git-common-dir")

#: How long `git` is given before the directory's basename is the answer. Short
#: on purpose: this runs inside a discovery tick, and a name is worth less than
#: the roster it would hold up.
COMMAND_TIMEOUT_SECONDS: Final = 5.0

#: What a Git common directory is called. A `rev-parse` answer that is not one is
#: an answer this build does not understand, and it falls back rather than
#: cutting a project name out of a shape it is guessing at.
GIT_DIRECTORY_NAME: Final = ".git"


#: How `git` is asked, and what it answered on stdout — `None` when it could not
#: be asked or refused. Injected for the reason the Claude lane injects its
#: roster command: a test that shelled out here would be measuring whichever
#: repositories the machine running it happens to have.
GitAnswer = Callable[[Path], Awaitable[str | None]]


class ProjectNames:
"""The project name for each workspace this lane has seen, read once each."""

def __init__(
self, *, ask: GitAnswer | None = None, timeout_seconds: float = COMMAND_TIMEOUT_SECONDS
) -> None:
self._timeout_seconds = timeout_seconds
self._ask = ask or self._run_git
#: realpath → the project name it resolved to.
self._known: dict[str, str] = {}

async def of(self, workspace: Path) -> str | None:
"""This workspace's project name, or `None` if there is no workspace.

`None` means the row carried no `cwd` at all — the lanes spell that
`Path()` — and it is the only case that yields no name. Everything else
resolves: a repository to its own directory name, anything else to the
workspace's basename.
"""
if not str(workspace).strip() or str(workspace) == ".":
return None
resolved = os.path.realpath(workspace)
remembered = self._known.get(resolved)
if remembered is not None:
return remembered

found = _project_in(await self._ask(Path(resolved)) or "") or Path(resolved).name.strip()
if not found:
return None
self._known[resolved] = found
return found

async def _run_git(self, workspace: Path) -> str | None:
"""Ask `git` about this workspace, and hand back what it said on stdout."""
try:
process = await asyncio.create_subprocess_exec(
*GIT_COMMAND,
cwd=workspace,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (OSError, ValueError) as unavailable:
_log.info("could not ask git about %s: %s", workspace, unavailable)
return None
try:
out, _ = await asyncio.wait_for(process.communicate(), self._timeout_seconds)
except TimeoutError:
process.kill()
await process.wait()
_log.info("git did not answer about %s within %ss", workspace, self._timeout_seconds)
return None
if process.returncode != 0:
return None
return out.decode("utf-8", errors="replace")


def _project_in(answer: str) -> str | None:
"""The project name inside one `rev-parse` answer, or `None` if it is not one.

Three shapes are refused, all of them legacy's (`labels.py:56-70`): more than
one line, a path that is not absolute, and a directory that is not `.git`.
Each would mean the answer is not the one this asked for, and a name cut out
of it would be this build guessing.
"""
text = answer.strip()
if not text or len(text.splitlines()) > 1:
return None
common = Path(text)
if not common.is_absolute() or common.name != GIT_DIRECTORY_NAME:
return None
return common.parent.name.strip() or None
7 changes: 6 additions & 1 deletion src/gpt_voicecoding/adapters/agent/claude/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from datetime import UTC, datetime
from pathlib import Path

from gpt_voicecoding.adapters.agent._project import ProjectNames
from gpt_voicecoding.adapters.agent.claude import discovery as claude_discovery
from gpt_voicecoding.adapters.agent.claude import inbox, stop_analysis, transcript_tail
from gpt_voicecoding.adapters.agent.claude.approval import (
Expand Down Expand Up @@ -197,6 +198,10 @@ def __init__(
#: The lane's one opener of a transcript file, shared by what a Session
#: stopped on (#75) and how far along it is (#76).
self._transcripts = TranscriptReader()
#: The project half of every Session Name this lane composes, read once
#: per workspace and kept for the life of the adapter (#78). Held here
#: rather than inside `discovery` so the cache outlives one tick.
self._projects = ProjectNames()
self._windows = ReplyWindowWatcher(
settings=self._settings, emit=self._emit, stopped_on=self.stopped_on
)
Expand Down Expand Up @@ -425,7 +430,7 @@ async def discover(self) -> LaneDiscovery:
five seconds, for the whole machine (`core/bridge.py:442`) — and
`inspect` reads the same rows.
"""
lane = await claude_discovery.discover()
lane = await claude_discovery.discover(projects=self._projects)
if not lane.enumerated:
return lane
return replace(lane, rows=tuple(self._row_with_stop(row) for row in lane.rows))
Expand Down
43 changes: 36 additions & 7 deletions src/gpt_voicecoding/adapters/agent/claude/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Final

from gpt_voicecoding.adapters.agent import _naming
from gpt_voicecoding.adapters.agent._project import ProjectNames
from gpt_voicecoding.seams.agent import (
LaneDiscovery,
SessionInspection,
Expand Down Expand Up @@ -107,7 +109,9 @@ async def run_command(argv: list[str]) -> CommandResult:
)


async def discover(*, run: Runner = run_command) -> LaneDiscovery:
async def discover(
*, run: Runner = run_command, projects: ProjectNames | None = None
) -> LaneDiscovery:
"""Every Claude Session running under this config directory, or why none.

**A lane that could not look says so; it never reports an empty machine.**
Expand Down Expand Up @@ -140,10 +144,10 @@ async def discover(*, run: Runner = run_command) -> LaneDiscovery:
)
)

return LaneDiscovery(rows=tuple(_rows(document)))
return LaneDiscovery(rows=tuple(await _rows(document, projects or ProjectNames())))


def _rows(document: list[Any]) -> list[SessionInspection]:
async def _rows(document: list[Any], projects: ProjectNames) -> list[SessionInspection]:
"""Every row that can be read, skipping the ones that cannot.

One unreadable row is not a broken roster, and refusing the whole document
Expand All @@ -158,10 +162,36 @@ def _rows(document: list[Any]) -> list[SessionInspection]:
if inspection is None:
_log.info("skipped a roster row this build cannot address: %r", row)
continue
found.append(inspection)
found.append(await _named(inspection, row, projects))
return found


async def _named(
inspection: SessionInspection, row: dict[str, Any], projects: ProjectNames
) -> SessionInspection:
"""The same row, carrying its Session Name.

**The task half is the roster's own `name`** — `workspace-claude-ed` and the
like — which is the official answer to "what is this Session called" and is
on every row from the moment the Session exists (#73). Nothing is asked of
the Session to get it and no transcript is opened for it, which is what makes
it stable enough to be the name the user speaks
(`legacy@1d32845:bridge/hook.py:215-253` had the Session report a task title
over the hook instead — *dropped, because* the amended #67 port table removed
that route on 2026-08-25).

The project half is the workspace's, resolved here because this is the lane
that knows the workspace. A row with neither half stays unnamed.
"""
task = _name(row)
if task is None:
return inspection
project = await projects.of(inspection.workspace)
if project is None:
return inspection
return replace(inspection, name=_naming.compose(project, task))


def _inspection(row: dict[str, Any]) -> SessionInspection | None:
"""One roster row as the seam holds it, or `None` if it is not addressable."""
session_id = row.get("sessionId")
Expand Down Expand Up @@ -195,7 +225,6 @@ def _inspection(row: dict[str, Any]) -> SessionInspection | None:
# began is not when it last did anything.
progress=None,
last_activity=None,
name=_name(row),
)


Expand All @@ -215,6 +244,6 @@ def _waiting_for(state: SessionState) -> WaitingFor:


def _name(row: dict[str, Any]) -> str | None:
"""The agent's own name for this Session — `workspace-claude-ed` and the like."""
"""The task half of this Session's name, straight off the official roster."""
name = row.get("name")
return name.strip() if isinstance(name, str) and name.strip() else None
5 changes: 5 additions & 0 deletions src/gpt_voicecoding/adapters/agent/codex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from typing import Any

from gpt_voicecoding import __version__
from gpt_voicecoding.adapters.agent._project import ProjectNames
from gpt_voicecoding.adapters.agent.codex import approvals as approval_wire
from gpt_voicecoding.adapters.agent.codex import discovery as codex_discovery
from gpt_voicecoding.adapters.agent.codex import thread_tail
Expand Down Expand Up @@ -186,6 +187,9 @@ def __init__(
)
#: What each loaded thread last said, read at most once per change.
self._turns = codex_discovery.TurnCache()
#: The project half of every Session Name this lane composes, read once
#: per workspace and kept for the life of the adapter (#78).
self._projects = ProjectNames()
#: How the machine's own process table is read. Injected for the reason
#: the daemon is: a test that shelled out to `ps` would be a test whose
#: answer depends on what the person running it happens to have open.
Expand Down Expand Up @@ -348,6 +352,7 @@ async def discover(self) -> LaneDiscovery:
processes=self._processes,
turns=self._turns,
daemon_note=self._daemon.note,
projects=self._projects,
)
if not lane.enumerated:
return lane
Expand Down
Loading
Loading