Repository navigation
Description
Groomed 2026-09-10. Kept as the adapter-consolidation issue the CLAUDE.md roadmap names (the duplicated Claude/opencode pairs in
harness_events_*,harness_actions_*,skein-harness). Absorbed #240: step one is a single per-kind registry with a Rust counterpart, replacing the three places that encode which program a kind spawns (managedProgram(),managed_program(),cmdForKind()) and validating the kind string at the Tauri boundary. The session-tracking spikes below still hold. Tier 3.
Goal
Give Skein a first-class, authoritative model of the underlying "session" each harness is running — and track it over the life of the pane, not just at spawn. Today a harness's session is a value we guess once at spawn and assume never changes. It does change (the user runs /clear, or /exits and then <harness> --resume in the shell), and Skein silently keeps following the old one. We need to know the current session so we can keep Live Context (Activity / Plan / Diff) attached to the right thing — and offer to attach to a new or previous session — for any harness kind, not just Claude.
This must be harness-agnostic from day one. We support claude, opencode, and will add copilot and others. The abstraction has to fit all of them.
Why now
While building #80's Live Context, we added a Claude-specific heuristic (detect_newer_session) that watched the cwd's session directory for a newer .jsonl and offered a "re-attach" button (PR #115, now closed). It was a band-aid and it broke for the real workflow: two Claude harnesses in one room share a worktree → share a cwd → share one ~/.claude/projects/<cwd>/ directory, so each harness saw the other's growing session file as "a newer session" every turn. The fix attempts confirmed the deeper truth: Skein has no model of which session belongs to which pane. This issue is that missing foundation. The re-attach / auto-follow UX should be layered on top of it afterward.
Current state (recon)
Claude — file-on-disk, cwd-derived
- Session id is pre-allocated at spawn (
crypto.randomUUID()→claude --session-id <uuid>).App.tsx~1999/2060,cmdForKind~973. - The follower tails
~/.claude/projects/<encode_cwd(cwd)>/<sessionId>.jsonl(encode_cwdreplaces/→-;session_jsonl_path), watching the parent dir non-recursively.harness_events_claude.rs. - A single project dir holds many session files (verified on disk: 7+
.jsonlin one dir). The session id lives inside every row, but the filename is the only per-session key — and the directory is shared by every pane in the cwd. - Gap: the follower is bound to one file path for life.
/clearorclaude --resume <id>in the same pane produces a different file the follower never looks at. Filename/mtime alone cannot say which pane owns which file.
opencode — server + port, DB-backed
- No pre-alloc. Each spawn gets a fresh
pick_free_port()and runsopencode --port <N> --hostname 127.0.0.1. The session id is captured after spawn from the SSEsession.createdevent on that port (primary), with a sqlite poll of~/.local/share/opencode/opencode.db(SELECT id FROM session WHERE directory=? AND time_archived IS NULL ORDER BY time_created DESC) as fallback.harness_events_opencode.rs,resume.rs,App.tsxcaptureOpencodeSessionId. - The per-pane port → SSE stream is authoritative while that process lives — opencode tells us busy/idle and which session was created. This is structurally better than Claude's file-sniffing.
- Gap: if the user
/exits and runsopencodeagain in the shell, the new process binds its own random port (not Skein's), so the adapter (pointing at the dead port) goes silent. Skein could rediscover via the opencode.db (new row for thisdirectory), but that reintroduces the same shared-cwd ambiguity (which DB row belongs to which pane).
copilot / byoh
- No session concept.
cmdForKindreturns a fixed argv;sessionIdis never set. The abstraction must allow "this kind has no session" cleanly.
The session value itself
Harness.sessionId?: string— a bare, write-once string (setHarnessSessionIdis first-writer-wins), persisted in the room blob, used on Skein restart to rewrite the spawn argv to resume form (--resume/--session/--continue) after validating the id still exists.types.ts,App.tsx,db.rs,resume.rs.- Gap: no lifecycle (pending / active / superseded / stale), no in-life rebinding, and
cmdForKind/resumeCmdare hard-coded switches with no per-kind registry.
Process layer (the missing authoritative signal)
- Skein spawns every PTY child itself;
portable-pty'sChildexposesprocess_id()— but Skein discards it (pty.rs: thePtystruct has no PID field). It has notcgetpgrp, no process-tree inspection, nolsof. So today there is zero OS-level link from a pane to the process actually reading/writing its session. - This matters because process ownership is the only unambiguous answer to "which pane does this session belong to" when panes share a cwd: PID 1234 (pane A) ≠ PID 5678 (pane B) no matter how many files/rows share the folder.
What we're actually trying to achieve (scenarios)
For every harness kind that has a session concept, Skein should keep tracking the right session through:
- In-tool reset — user runs
/clear(or the kind's equivalent) inside the running harness → a new underlying session begins in the same pane. Skein should notice and follow it (Live Context keeps working). - Exit + resume in shell — user
/exits the harness, gets the "Press Enter for shell" prompt, then typesclaude --resume <id>/opencode --session <id>/opencodein that shell. The new agent process is a child of Skein's shell PTY. Skein should detect the new (or resumed) session and re-bind to it — same pane, same harness, no need to recreate the harness. - Multiple harnesses, one room/cwd — two
claude(or twoopencode) panes sharing a worktree must never be confused for each other. A session change in pane A must attribute to pane A only. - Resume on Skein restart — unchanged in spirit (already works), but should fall out of the same model rather than being a separate code path.
- Attach to a previous session — because we now know the set of sessions for a cwd and which is current, we can offer "attach to an older session" deliberately (foundation for an in-Skein session switcher).
Once the model is authoritative, the UX on top can be automatic follow (preferred for the common case) and/or an explicit affordance — and crucially it won't misfire across sibling panes.
Proposed direction (not a locked design)
A per-harness SessionTracker abstraction, one implementation per kind, exposing a small harness-agnostic interface:
currentSession(): SessionRef | None— the session this pane is actually on right now (observed, may change).- an event/stream of session-changed transitions (old → new), with the cause when known (in-tool reset, restart-in-shell, resume).
- the data needed to attach/resume (so the existing follower + Live Context re-point cleanly).
Backed by, per kind:
- claude: the cwd's project dir as a session registry; disambiguated by process ownership.
- opencode: the per-pane SSE/port as the live signal; the opencode.db (by
directory) for rediscovery after a shell restart; disambiguated by process ownership where the DB is shared. - copilot/byoh: a no-op tracker (no session).
Harness.sessionId: string | undefined evolves into observed, lifecycle-aware state (current id + status), no longer write-once.
Enabling capability: process ownership
Capture each PTY child's PID (cheap — portable-pty already gives it to us) and, when a candidate session file/row appears, resolve which pane's process tree owns it. This is the authoritative disambiguator for shared-cwd. It requires platform-specific file/connection → PID lookup (macOS libproc/lsof, Linux /proc, Windows handle API) — we run macOS and Windows, so plan for both.
Open questions / spikes to resolve before locking the design
- Does Claude
/clearcreate a new session file, or reuse the current one? This materially changes the design — if only full restarts create new files, Skein can largely own that path. Must verify empirically. - Cross-platform file→PID (and socket→PID) cost. Spike macOS + Windows feasibility; decide if process-ownership is the foundation or a later enhancement with a cheaper interim (e.g. opencode leans on its SSE/DB; Claude uses a narrower heuristic until ownership lands).
- opencode shell-restart rediscovery. Can we reliably re-find a new opencode process's session (new random port) via the opencode.db
directoryrow + ownership, and reconnect SSE? Is there a way to make opencode use a Skein-known port on user restart? - Should Skein own the restart path? e.g. a "restart / new session" affordance Skein runs with a known id, sidestepping detection for the common case (typed
/clearstill needs detection). - Resume-failure handling. If a resumed id is stale, fall back to
--continue/ fresh rather than erroring in the PTY.
Non-goals
- Re-attach / auto-follow UX polish (separate, layered on top once the model is solid).
- Changing the shared-worktree model (panes intentionally share a cwd — the model must cope with that, not remove it).
- copilot/byoh session features (they have none).
Acceptance criteria (foundation)
- A harness-agnostic SessionTracker interface with claude + opencode implementations and a no-op for sessionless kinds.
- Skein knows each pane's current session and emits a session-changed signal, correctly attributed per-pane even with multiple harnesses in one cwd (covered by tests).
- Scenarios 1–4 above are detectable (UX wiring can follow).
- PTY child PID captured and available to the trackers (or a documented decision to defer ownership with a named interim).
- Spikes 1–3 answered and written up before implementation locks.
Supersedes the closed PR #115 band-aid. Relates to #80 (Live Context consumes the session signal) and #50 (activity/phase tracking attaches per session).
Activity
Svampen commentedon Jun 23, 2026
Spike findings (round 1) — partly overturns the "process ownership via file→PID" direction
Ran the three spikes (macOS empirical + Claude Code docs via the in-tool guide). One result invalidates an assumption in the issue body above, and the net picture is clearer and more tractable for the scenario we actually care about.
Spike 1 — does Claude
/clearcreate a new session file?Most likely YES (new session id → new
.jsonl), but worth one direct confirmation./clearas "Start a new conversation with empty context. The previous conversation stays available in/resume… To free up context while continuing the same conversation, use/compactinstead." That wording — a new conversation, the previous one separately resumable/labelable, and/compactoffered as the same-conversation alternative — indicates/clearforks a new session file. Matches the original bug report (after/clear, the follower lost tracking).--resume <id>/--continueappend to the existing<id>.jsonl(documented)./clear, watch the project dir for a new file) before locking design, but proceed on "/clear = new file".Spike 2 — can we authoritatively attribute a session to a pane? (the disambiguator)
This is where the assumption broke.
.jsonlopen. Tested against a live session's file (the transcript being written this very moment):lsoffound no open handle, across 4 polls over 2s. Claude opens/appends/closes per write. →lsof <file>→ owning PID does not work for Claude. The "process ownership via open file handle" enabling-capability proposed above is out for Claude.pid/ppidfield in the JSONL rows; no pidfile / lock file convention (confirmed via docs). So there is no OS-level link from a Claude session file to its writer PID.lsof -nP -iTCP:<port>→ PID). Sockets stay open, so this is reliable — relevant for opencode's port.pgrep -P,ps -o ppid=), and crucially: Skein spawns the agent as a direct PTY child. Live ancestry of a running Claude pane:portable-pty'schild.process_id()is the agent PID — Skein just needs to keep it (todaypty.rsdiscards it).ps):claude --session-id <uuid>/claude --resume <uuid>opencode --port <N> --hostname 127.0.0.1 --session <id>/--continueSpike 3 — opencode shell-restart rediscovery
Feasible. A user-restarted
opencode(new random port) is a descendant of the pane's PTY child; we can enumerate descendants, read its argv for--port/--session, and reconnect SSE to the new port (cross-checked via socket→PID). opencode is fully process-observable (argv carries port+session; socket→PID; SSE is authoritative while alive).Revised direction (grounded in the spike)
The signals available differ sharply by kind, so the harness-agnostic SessionTracker must lean on process-tree + argv inspection (which works for both) rather than file→PID (which doesn't work for Claude):
The scenario we actually set out to fix —
/exitthen<harness> --resume <id>in the shell — is authoritatively solvable for BOTH harnesses: the new agent is a descendant of the pane's known PTY child PID, and its argv contains the resumed session id. No file sniffing, no ambiguity.Coverage map:
--session-id)session.created/ argv / sqlite--resume <id>/--session <id>in shellclaude/opencode, no id)/clear(new file, same process)The one genuinely unsolvable-by-OS case is: multiple Claude panes sharing a cwd + in-tool
/clear. There is no authoritative signal. Options for that residual:--session-id, so detection isn't needed. The user typing/clearmanually inside Claude remains the only residual, handled best-effort.Concrete capability changes implied
pty.rs(it's already in hand fromspawn_command) and expose it to the trackers.libproc/proc_pidpath+sysctl(KERN_PROCARGS2)for argv (or shell out tops)./clear, rely on single-harness-per-cwd or a Skein-owned reset.All of the above is macOS-confirmed. Windows equivalents to validate (#108 dogfood):
CreateToolhelp32Snapshotor WMIWin32_Process.Win32_Process.CommandLine(orNtQueryInformationProcess).GetExtendedTcpTable.claude/opencodekeeps its session file open (may differ from macOS).Net: drop file→PID for Claude; build the tracker on process-tree + argv (works for both kinds and nails the exit+resume-in-shell scenario); treat multi-claude-same-cwd
/clearas the one case to handle by Skein owning the action rather than detecting it.Svampen commentedon Jun 23, 2026
Spike findings (round 2) — native harness integration should be the PRIMARY channel, not OS sniffing
Followed the round-1 thread ("why is Skein reverse-engineering this when it owns the flow?") by verifying whether the harnesses can just tell Skein their session, via their own hook/plugin systems. Verified against Claude Code docs and the opencode docs/source. Result: this inverts the design — native integration becomes primary; OS/file sniffing demotes to a fallback.
Why "Skein owns the PTY" isn't enough
Owning the PTY gives Skein the transport (raw bytes in/out), not the meaning. A harness's session id is never printed to the terminal, so it isn't in the byte stream —
/clearswitches session invisibly. Scraping typed shell commands (claude --resume X) is possible from stdin but breaks on aliases/line-editing/history and can't see in-tool/clear. The session identity lives in the harness's private state; the clean way to get it is to have the harness emit it.Claude Code hooks — ✅ authoritative, solves the hard cases
SessionStarthook fires on startup,/clear,/resume,/compact, with asourcefield distinguishing them.SessionEndfires on exit with areason.session_id,cwd,transcript_path,hook_event_name(+source, optionalmodel).SKEIN_HARNESS_ID=<id>(and e.g.SKEIN_HOOK_URL=http://127.0.0.1:<port>/hook) lets the hook report{harness_id, session_id, source, cwd}— unambiguous even when panes share a cwd, because the id is per-pane./exit → shell → claude --resume X, so the exit+resume-in-shell scenario and bare restart are both solved authoritatively, as is/clear(the OS-unsolvable case from round 1)..claude/settings.local.json(gitignored; no global edit). Skein can write it into each worktree.SessionEndcan't block and has ~1.5s budget; a crash/SIGKILL won't fire it (re-sync on nextSessionStart).opencode —⚠️ upstream-limited; best-effort stays best-effort
.opencode/plugins/, aneventhook) and the SSE/eventstream we already use.session.createdfires for new sessions;session.updated/idle/message.updatedfire for the worked-on session; a silent switch emits nothing. Upstream: [FEATURE]: SessionStart hook for session lifecycle events anomalyco/opencode#5409 (request for session-start/select hook — open), #7451 (session-level SSE listener — closed not planned).OPENCODE=1); the instance handle is the--portSkein already assigns.OPENCODE_CONFIG_DIR).GET /sessionordered bytime_updatedfor the spawn dir. If exact switch-detection matters, push the upstream issue rather than build a kludge.SSE reliability note (opencode)
Recent opencode SSE bugs (stream closing right after
server.connected; dropped publishes in 1.14.42+ — anomalyco/opencode#26697, #27966). Pin/test the version we ship against.Revised architecture (supersedes the round-1 "process ownership" direction)
Invert the dependency: each harness reports its session to Skein through its native integration point; Skein supplies a stable per-pane identity + a local receiver.
Harness-agnostic contract: "tell Skein
{harness_id, session_id, event, cwd}when your session starts / changes / ends."SKEIN_HARNESS_ID(+ callback address) injected on every spawn, including shell respawns; a tiny local receiver (HTTP on localhost, or a watched drop-file); normalization into the per-kind SessionTracker from the issue body./clear,/resume, exit+resume-in-shell, bare restart, shared-cwd.--port+ session-list reconciliation; best-effort on silent switches (upstream-bounded). Optional plugin later if upstream adds a select event.Honest costs
Open items
/clearfiresSessionStart source=clearin a live interactive TUI (docs say yes)..claude/settings.local.json(recommended) vs user-global, and how to merge with a user's existing hooks.OPENCODE_CONFIG_DIR) only if/when we need more than SSE+port gives.Net: build the foundation on native harness integration (hooks/plugins) keyed by a Skein-injected per-pane env id; keep OS/file sniffing as fallback only. This is authoritative for Claude (incl.
/clear), bounded-by-upstream for opencode, and cross-platform.Svampen commentedon Jun 24, 2026
Spike round 3 —
/clearconfirmed empirically; Claude adapter design lockedRan the live test (a
SessionStart/SessionEndhook logging Claude's payload + an injectedSKEIN_HARNESS_ID, in a normal terminal:SKEIN_HARNESS_ID=test-pane-A claude→ turn →/clear→ turn → exit).Result (verbatim, trimmed)
Two files on disk:
7ac38652….jsonland7bcd13cb….jsonl.Confirmed
/clearcreates a NEW session id + NEW.jsonlfile (open item Allow the user to pick which shell Skein spawns #1 — resolved; the docs' "same file" reading was wrong)./clearemits a clean old→new transition:SessionEnd(reason=clear)for the old id, immediately followed bySessionStart(source=clear)for the new id. The adapter receives both ids — it can re-point the follower precisely with no file diffing.SKEIN_HARNESS_IDpropagates to every hook event, including across/clear. This is the per-pane identity that disambiguates panes sharing a cwd — the case no OS/file approach could solve.SessionEnd.reasonis meaningful (clearvsprompt_input_exitvsresume/logout) → Skein can distinguish "cleared, re-point & keep harness" from "user exited, await restart" for free.Decisions locked (round-2 open items resolved)
$SKEIN_HARNESS_ID([ -z "$SKEIN_HARNESS_ID" ] && exit 0→ clean no-op for non-Skein sessions). No per-worktree settings files, no merge with the user's own hooks. (Verified: plugin hooks are behaviourally identical to settings hooks — same payload, same env inheritance.) Build-time detail still open: the exact persistent local-install path (--plugin-dirvs managed plugin dir); hook behaviour is identical regardless, so this doesn't block design.SKEIN_HOOK_URL=http://127.0.0.1:<port>/session(+ a per-session token) at spawn; the hookcurls the payload; Skein binds 127.0.0.1 only. No-op ifSKEIN_HOOK_URLunset.SKEIN_HARNESS_IDinjected on every spawn (incl. shell respawns), so the chain/exit → shell → claude --resume Xstill reports the right pane.The Claude adapter is therefore: plugin (self-gated install) + localhost endpoint (transport) +
SKEIN_HARNESS_ID(identity). Authoritative for spawn,/clear,/resume, exit+resume-in-shell, bare restart, and multi-pane shared-cwd. Cross-platform (no OS process introspection needed for Claude).Still open
GET /sessionreconciliation. A future opencode plugin could POST to the same Skein endpoint if upstream adds a select event.SKEIN_HARNESS_ID/SKEIN_HOOK_URLenv injection in spawn, the plugin payload, the Skein receiver, normalization into the SessionTracker) — to be drafted as the build task.docs: grok-build recon — learnings mapped to roadmap arcs + backlog a…
Svampen commentedon Jul 16, 2026
Design input from the grok-build recon (https://github.com/timeloop-vault/skein/blob/main/docs/grok-build-recon-2026-07-16.md, §1):
updates.jsonl). Strong evidence for adopting ACP's vocabulary (SessionUpdate/ToolCallUpdate+Diff /PlanEntry/request_permission/ turn events) as our normalized internal event model, with per-harness transports (JSONL tail, SSE, ACP stdio) at the edge. Caveat: their queueing/steering/review verbs are allx.ai/*extensions, not core ACP — a universal adapter won't erase per-harness divergence for advanced features.tool_taxonomy.rsis the missing model forliveContext/payload.ts: one canonical envelope{name, kind (open-set), namespace, label, read_only, input}wherelabelis the cross-harness join key andinputprojects only cross-harness fields (raw payload as fallback), plus aclaude_alias.rstable mapping Claude tool names onto it — built because "two tables drifted apart".Svampen commentedon Sep 10, 2026
Backlog grooming 2026-09-10. Reworded as the harness-adapter consolidation issue (the CLAUDE.md roadmap item). Absorbed #240: a single per-kind registry with a Rust counterpart is the first concrete step; the duplicated Claude/opencode adapter pairs and authoritative session tracking follow on it.
[-]Authoritative per-harness session tracking (harness-agnostic foundation)[/-][+]Harness-adapter consolidation: one per-kind registry, then authoritative session tracking[/+]refactor(#116): one per-kind harness registry shared by Rust and TS
fix(#116): follow a Claude /clear onto its new session
fix(#116): follow a Claude /resume onto the resumed session
Svampen commentedon Sep 22, 2026
Step four is on branch
skein/skein-116-4(1482669). This is the last step of #116 to ship here. The rest is split into new issues.SessionStart source=fork(/branch,/fork,--fork-session) carries the fork's new id and its own transcript, so it is now followed likeclearandresume./new:session.creatednow carriesparentID. A new root session is followed; a task-tool child session (always hasparentID) is never captured or followed./sessionspicker: still publishes nothing upstream ([FEATURE]: SessionStart hook for session lifecycle events anomalyco/opencode#5409, closed stale; #18007 not merged). So the first user message in a different root session is the switch signal. Root vs child is decided fromsession.created/session.updated, else by asking the server (GET /session/<id>). A failed lookup follows nothing./exits (SessionEnd hook doesn't fire on /exit command anthropics/claude-code#17885). It adds no signal Skein lacks.Split out:
Once 1482669 lands, #116 can close.
fix(#116): follow opencode /new and session switches, and Claude forks
Merge origin/main into skein/skein-76
fix(#336): read a resumed harness as waiting when its transcript ends…