← back to Terminal Status
TK-11665: bind a session's ticket via ledger agent claude@TERM_SESSION_ID
4187d75c197eae2c52b00097ca04ff0072dfd710 · 2026-09-14 00:30:03 -0700 · Steve Abrams
Add a 4th, last-resort binding source to ticket_binding.discover(): when no
MCP-correlation and no argv ticket bind a live session, resolve the session's
TERM_SESSION_ID from its process ENV (keyed PID->TSID, never tty) and bind the
most recent assign/create/action/comment event logged under claude@<TSID> (the
tk CLI's fallback identity when a session sets no TK_AGENT).
No epoch floor: TERM_SESSION_ID is a stable session identity across a --continue
resume in the same pane, so events predating this process's start are that
session's own prior work -- this is the fix for the resumed 'claude --continue'
class that permanently read "TK REQUIRED". Same `in known` safety as every other
source (never invents a ticket); never overrides a bound argv source; cannot
reach a self-chosen TK_AGENT (the honest KNOWN CEILING). Reads env via `ps eww`
minus the `-o args=` prefix to avoid the args+env concatenation trap.
Ships a positive test (resumed session, pre-start events bind) and a non-vacuous
negative test (read-noise / unknown ticket / no TSID / argv-present-no-override
all correctly abstain) -- proven to go RED on an injected fault.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVgEj4WxSqB62ieGpgrixR
Files touched
M test_terminal_status.pyM ticket_binding.py
Diff
commit 4187d75c197eae2c52b00097ca04ff0072dfd710
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Sep 14 00:30:03 2026 -0700
TK-11665: bind a session's ticket via ledger agent claude@TERM_SESSION_ID
Add a 4th, last-resort binding source to ticket_binding.discover(): when no
MCP-correlation and no argv ticket bind a live session, resolve the session's
TERM_SESSION_ID from its process ENV (keyed PID->TSID, never tty) and bind the
most recent assign/create/action/comment event logged under claude@<TSID> (the
tk CLI's fallback identity when a session sets no TK_AGENT).
No epoch floor: TERM_SESSION_ID is a stable session identity across a --continue
resume in the same pane, so events predating this process's start are that
session's own prior work -- this is the fix for the resumed 'claude --continue'
class that permanently read "TK REQUIRED". Same `in known` safety as every other
source (never invents a ticket); never overrides a bound argv source; cannot
reach a self-chosen TK_AGENT (the honest KNOWN CEILING). Reads env via `ps eww`
minus the `-o args=` prefix to avoid the args+env concatenation trap.
Ships a positive test (resumed session, pre-start events bind) and a non-vacuous
negative test (read-noise / unknown ticket / no TSID / argv-present-no-override
all correctly abstain) -- proven to go RED on an injected fault.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVgEj4WxSqB62ieGpgrixR
---
test_terminal_status.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++
ticket_binding.py | 65 +++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 133 insertions(+), 1 deletion(-)
diff --git a/test_terminal_status.py b/test_terminal_status.py
index 30d9677..30279d7 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -117,6 +117,75 @@ class StatusTests(unittest.TestCase):
"ts": dt.datetime.fromtimestamp(self.owner.epoch - 60, dt.timezone.utc).isoformat()})
for t in tickets))
+ def session_ledger(self, tsid, *events, created=()):
+ """Write a ledger of claude@<tsid> events (each (type, ticket, offset_s))
+ plus optional standalone create events, for the TK-11665 session route."""
+ path = self.root / ".claude/tickets/events.jsonl"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ lines = []
+ for tk in created:
+ lines.append(json.dumps({"id": tk, "type": "create",
+ "ts": dt.datetime.fromtimestamp(self.owner.epoch - 3600, dt.timezone.utc).isoformat()}))
+ for etype, ticket, offset in events:
+ lines.append(json.dumps({"id": ticket, "type": etype,
+ "agent": "claude@" + tsid,
+ "ts": dt.datetime.fromtimestamp(self.owner.epoch + offset, dt.timezone.utc).isoformat()}))
+ path.write_text("\n".join(lines))
+
+ def test_session_ledger_binds_resumed_session_via_term_session_id(self):
+ # TK-11665. A bare `claude --continue` carries NO ticket in argv, so every
+ # argv source abstains. Its work is logged under claude@<TERM_SESSION_ID>,
+ # and a resume in the same pane keeps that id -- so events written BEFORE
+ # this process started (offset -3600, i.e. before owner.epoch) are still
+ # this session's own work and MUST bind. Keyed PID->TERM_SESSION_ID via env.
+ tsid = "w7t0p0:E2D0289F-FA6C-45F7-8D42-42A35D406E46"
+ self.session_ledger(tsid,
+ ("assign", "TK-11665-fix-x", -3600),
+ ("action", "TK-11665-fix-x", -1800),
+ created=("TK-11665-fix-x",))
+ found, _ = ts.tickets.discover(
+ self.root, self.rows, ts.owners(self.rows), ts.ancestors,
+ argv={123: "claude --continue"}, env={123: tsid})
+ self.assertEqual(found.get("ttys010"),
+ {"id": "TK-11665", "at": self.owner.epoch - 1800,
+ "source": "session_ledger"})
+
+ def test_session_ledger_is_non_vacuous_and_never_launders(self):
+ # TK-11665 NEGATIVE test (TK-11431 rule 3): the source must go quiet on
+ # every shape it must not bind, so a green result is evidence, not habit.
+ tsid = "w7t0p0:AAAAAAAA-0000-0000-0000-000000000000"
+ base = dict(argv={123: "claude --continue"}, env={123: tsid})
+ bind = lambda: ts.tickets.discover(self.root, self.rows,
+ ts.owners(self.rows), ts.ancestors, **base)
+
+ # (a) `read` events are board-viewing NOISE, never a binding.
+ self.session_ledger(tsid, ("read", "TK-11665-x", -60), created=("TK-11665-x",))
+ self.assertNotIn("ttys010", bind()[0])
+
+ # (b) a ticket the session touched but that was NEVER created cannot bind
+ # (the `in known` safety -- it can never invent a ticket).
+ self.session_ledger(tsid, ("action", "TK-99999999-ghost", -60))
+ self.assertNotIn("ttys010", bind()[0])
+
+ # (c) no TERM_SESSION_ID in env (a session under a self-chosen TK_AGENT --
+ # the ticket's KNOWN CEILING): stays honest, binds nothing.
+ self.session_ledger(tsid, ("action", "TK-11665-x", -60), created=("TK-11665-x",))
+ self.assertNotIn("ttys010",
+ ts.tickets.discover(self.root, self.rows, ts.owners(self.rows),
+ ts.ancestors, argv={123: "claude --continue"},
+ env={})[0])
+
+ # (d) an argv ticket present: the session route must NEVER override a bound
+ # argv source (it is the last resort, not a competitor).
+ self.session_ledger(tsid,
+ ("action", "TK-11665-x", -60),
+ created=("TK-11665-x", "TK-11317"))
+ found, _ = ts.tickets.discover(
+ self.root, self.rows, ts.owners(self.rows), ts.ancestors,
+ argv={123: "claude drive TK-11317 to done"}, env={123: tsid})
+ self.assertEqual(found["ttys010"]["source"], "main_process_argument")
+ self.assertEqual(found["ttys010"]["id"], "TK-11317")
+
def bind(self, argv):
return ts.tickets.discover(self.root, self.rows, ts.owners(self.rows),
ts.ancestors, argv=argv)
diff --git a/ticket_binding.py b/ticket_binding.py
index 2c8fce0..4b38908 100644
--- a/ticket_binding.py
+++ b/ticket_binding.py
@@ -60,9 +60,17 @@ def timestamp(value):
return 0
-def discover(root, rows, live, chain, argv=None):
+def discover(root, rows, live, chain, argv=None, env=None):
"""Attribute events only to a currently live main agent's own MCP process."""
known, claims, actions = set(), {}, {}
+ # TK-11665: the session's OWN ledger identity. When a session sets no TK_AGENT,
+ # tk logs it under `claude@<TERM_SESSION_ID>` (the tk CLI's fallback identity --
+ # see the agent line in ~/Projects/ticket-system/tk). session_events[tsid] holds
+ # the MOST RECENT assign/create/action/comment that session wrote (never `read`,
+ # which is board-viewing noise, nor `status`/`blocker`). Bound below to a live
+ # session resolved PID->TERM_SESSION_ID, so a bare `claude`/`claude --continue`
+ # that carries no ticket in its argv still shows what it is working on.
+ session_events = {}
descendants = {}
for pid, p in rows.items():
nearest = next((a for a in chain(pid, rows) if a.runtime), None)
@@ -83,6 +91,18 @@ def discover(root, rows, live, chain, argv=None):
continue
if event.get("type") == "create":
known.add(ticket)
+ # TK-11665: capture the session-identity ledger in this same pass (no
+ # second file read). No epoch floor here -- unlike the correlation-id
+ # PID match below, TERM_SESSION_ID is a STABLE session identity across a
+ # --continue resume in the same pane, so events predating this process's
+ # start ARE this session's own prior work and must be kept.
+ agent = event.get("agent", "")
+ if agent.startswith("claude@") and event.get("type") in (
+ "assign", "create", "action", "comment"):
+ tsid = agent[len("claude@"):]
+ at = timestamp(event.get("ts"))
+ if tsid and at >= session_events.get(tsid, {}).get("at", 0):
+ session_events[tsid] = {"id": ticket, "at": at}
match = CID.fullmatch(event.get("correlation_id", ""))
if not match or int(match[1]) not in descendants:
continue
@@ -121,6 +141,36 @@ def discover(root, rows, live, chain, argv=None):
_record_enum_blind("ticket_binding ps -p: " + type(exc).__name__)
except Exception:
pass
+ # TK-11665: resolve each live session's TERM_SESSION_ID from its process ENV
+ # (keyed on PID). `ps eww` prints ARGS AND ENV concatenated, so the env is the
+ # tail after the clean args string -- reading TERM_SESSION_ID off the raw output
+ # would hit the token sitting in a PROMPT argument (the measurement trap the
+ # ticket calls out). We strip the args prefix (already fetched above via
+ # `-o args=`) and parse only the remainder; if it does not start with the args
+ # we skip that pid rather than risk the trap. Optional enrichment like the argv
+ # read: a slow/absent ps degrades the label, never the paint.
+ if env is None and live:
+ env = {}
+ try:
+ output = subprocess.run(["ps", "eww", "-o", "pid=,command=",
+ "-p", ",".join(str(o.pid) for o in live.values())],
+ capture_output=True, text=True, timeout=_PS_TIMEOUT)
+ for line in output.stdout.splitlines():
+ parts = line.strip().split(None, 1)
+ if len(parts) != 2:
+ continue
+ pid, full = int(parts[0]), parts[1]
+ args = (argv or {}).get(pid, "")
+ remainder = full[len(args):] if args and full.startswith(args) else ""
+ m = re.search(r"TERM_SESSION_ID=(\S+)", remainder)
+ if m:
+ env[pid] = m.group(1)
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ try:
+ from terminal_status import _record_enum_blind
+ _record_enum_blind("ticket_binding ps eww: " + type(exc).__name__)
+ except Exception:
+ pass
for tty, owner in live.items():
if result[tty]:
continue
@@ -139,6 +189,19 @@ def discover(root, rows, live, chain, argv=None):
if len(ids) == 1 and next(iter(ids)).upper() in known:
result[tty] = {"id": next(iter(ids)).upper(), "at": owner.epoch,
"source": "main_process_argument"}
+ # TK-11665: LAST resort -- only when no MCP-correlation and no argv ticket
+ # bound. The session's own ledger identity (PID->TERM_SESSION_ID) reflects
+ # what it is working on NOW, which is the only signal that survives a
+ # --continue resume (argv carries no ticket there). Same `in known` safety
+ # as every other source: it can never invent a ticket. Cannot reach a
+ # session that logs under a self-chosen TK_AGENT (the ticket's KNOWN CEILING);
+ # that stays honest "TK REQUIRED".
+ if not result[tty]:
+ tsid = (env or {}).get(owner.pid)
+ cand = session_events.get(tsid) if tsid else None
+ if cand and cand["id"] in known:
+ result[tty] = {"id": cand["id"], "at": cand["at"],
+ "source": "session_ledger"}
return {tty: value for tty, value in result.items() if value}, known
← 664d1de TK-11672: fix permanently-red terminal-status test via injec
·
back to Terminal Status
·
TK-11666: measure the TK-REQUIRED false-positive rate (the m 9731e7f →