syntaur 0.34.0 → 0.35.0
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/dist/index.js +146 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/SESSION-ID-RESOLUTION.md +117 -0
- package/platforms/claude-code/agents/syntaur-expert.md +1 -1
- package/platforms/claude-code/commands/track-session/track-session.md +5 -4
- package/platforms/claude-code/hooks/hooks.json +1 -1
- package/platforms/claude-code/hooks/session-cleanup.sh +16 -8
- package/platforms/claude-code/skills/clear-assignment/SKILL.md +1 -1
- package/platforms/claude-code/skills/complete-assignment/SKILL.md +1 -1
- package/platforms/claude-code/skills/grab-assignment/SKILL.md +5 -4
- package/platforms/claude-code/skills/save-session-summary/SKILL.md +15 -6
- package/platforms/claude-code/skills/track-session/SKILL.md +5 -4
- package/platforms/codex/agents/syntaur-operator.md +1 -1
- package/platforms/codex/commands/save-session-summary.md +1 -1
- package/platforms/codex/scripts/session-cleanup.sh +63 -6
- package/platforms/codex/skills/clear-assignment/SKILL.md +1 -1
- package/platforms/codex/skills/complete-assignment/SKILL.md +1 -1
- package/platforms/codex/skills/grab-assignment/SKILL.md +5 -4
- package/platforms/codex/skills/save-session-summary/SKILL.md +15 -6
- package/platforms/codex/skills/track-session/SKILL.md +5 -4
- package/platforms/cursor/hooks/README.md +49 -0
- package/platforms/hermes/plugins/syntaur/__pycache__/__init__.cpython-312.pyc +0 -0
- package/platforms/hermes/plugins/syntaur/__pycache__/boundary.cpython-312.pyc +0 -0
- package/platforms/opencode/plugin/syntaur-session-env.js +30 -0
- package/platforms/pi/README.md +50 -0
- package/skills/clear-assignment/SKILL.md +1 -1
- package/skills/complete-assignment/SKILL.md +1 -1
- package/skills/grab-assignment/SKILL.md +5 -4
- package/skills/save-session-summary/SKILL.md +15 -6
- package/skills/track-session/SKILL.md +5 -4
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Cross-agent session-id resolution
|
|
2
|
+
|
|
3
|
+
Session identity is an **ambient property of the running process**, resolved on
|
|
4
|
+
demand — never persisted-and-looked-up in the shared `.syntaur/context.json`
|
|
5
|
+
scalar (a co-tenant sharing the workspace clobbers it). The CLI does this in
|
|
6
|
+
`src/utils/session-id.ts` (`resolveOwnSessionId`), with six layers:
|
|
7
|
+
|
|
8
|
+
1. explicit `--session-id`
|
|
9
|
+
2. injected env var: `CLAUDE_CODE_SESSION_ID` / `OPENCODE_SESSION_ID` / `PI_SESSION_ID`
|
|
10
|
+
3. agent side channel (Cursor nonce → `conversation_id`)
|
|
11
|
+
4. ancestor-pid → runtime marker (`~/.claude/sessions/<pid>.json`, then the
|
|
12
|
+
generic `~/.syntaur/runtime/sessions/<pid>.json`), pid-reuse-guarded by `procStart`
|
|
13
|
+
5. cwd/mtime transcript scan (last automatic resort; ambiguous under co-tenancy)
|
|
14
|
+
6. legacy `context.json.sessionId` hint (only when the caller opts in)
|
|
15
|
+
|
|
16
|
+
**The consuming half (layers 2, 4, 5, 6) is implemented and unit-tested today.**
|
|
17
|
+
Each agent's job is only to make its real id reachable by layer 2, 3, or 4 — i.e.
|
|
18
|
+
to **normalize the key**, never to synthesize an id. Per-agent status and the
|
|
19
|
+
exact injector below.
|
|
20
|
+
|
|
21
|
+
## Generic runtime marker (layer 4)
|
|
22
|
+
|
|
23
|
+
Any agent whose start/early hook learns the real id but cannot inject env can
|
|
24
|
+
stamp a marker that both the resolver and the Codex cleanup hook read:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
~/.syntaur/runtime/sessions/<agentPid>.json
|
|
28
|
+
= { "sessionId": "<real id>", "agent": "<name>", "cwd": "<abs>",
|
|
29
|
+
"procStart": "<ps -o lstart= string>", "writtenAt": <epoch ms> }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`procStart` guards against pid reuse (compared to `ps -o lstart= -p <pid>`).
|
|
33
|
+
Helpers: `writeRuntimeMarker` / `readRuntimeMarker` in `src/utils/session-id.ts`.
|
|
34
|
+
Override the dir in tests/hooks via `$SYNTAUR_RUNTIME_SESSIONS_DIR`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Claude Code — EXACT (shipped, no new runtime code)
|
|
39
|
+
|
|
40
|
+
Native `CLAUDE_CODE_SESSION_ID` env is injected into every child process, so a
|
|
41
|
+
`syntaur` command is a child and layer 2 hits. Confirmed live:
|
|
42
|
+
`CLAUDE_CODE_SESSION_ID` and the ancestor-pid file `~/.claude/sessions/<pid>.json`
|
|
43
|
+
resolve to the same id. The SessionStart hook still mirrors the id into
|
|
44
|
+
`context.json` as a legacy hint (back-compat). **Fixes the reported bug.**
|
|
45
|
+
|
|
46
|
+
## OpenCode — injector ships as a plugin (live-build gate)
|
|
47
|
+
|
|
48
|
+
OpenCode's plugin API exposes `plugin.trigger("shell.env", { sessionID })` per
|
|
49
|
+
spawn (`@opencode-ai/plugin`). A ~5-line plugin injects `OPENCODE_SESSION_ID`,
|
|
50
|
+
which layer 2 then reads. Reference plugin: `platforms/opencode/plugin/syntaur-session-env.js`.
|
|
51
|
+
|
|
52
|
+
- **Caveat:** the V2 `core` bash tool is not yet wired to `shell.env`
|
|
53
|
+
(`// TODO` in `packages/core/src/tool/bash.ts`); the `opencode` `ShellTool`
|
|
54
|
+
path is. Verify on the target build.
|
|
55
|
+
- **Status:** Syntaur's current OpenCode integration is **adapter-file-only**
|
|
56
|
+
(`platforms/opencode/README.md`) — it has no persistent-plugin install path
|
|
57
|
+
yet. The plugin is shipped as a reference artifact; wiring it into a
|
|
58
|
+
Syntaur-managed install is follow-up work.
|
|
59
|
+
- **Live verification gate (cannot run here):** from an OpenCode tool call,
|
|
60
|
+
`echo $OPENCODE_SESSION_ID` returns the conversation/session id.
|
|
61
|
+
|
|
62
|
+
## Pi — injector ships as an extension (live-build gate)
|
|
63
|
+
|
|
64
|
+
Pi extensions expose `session_start` (capture `ctx.sessionManager.getSessionId()`)
|
|
65
|
+
and a bash `spawnHook` that injects env per spawn. Inject `PI_SESSION_ID` (layer
|
|
66
|
+
2). Reference: `platforms/pi/extension/syntaur-session-env.js` + `platforms/pi/README.md`.
|
|
67
|
+
|
|
68
|
+
- **Status:** Syntaur ships a Pi extension at
|
|
69
|
+
`platforms/pi/extensions/syntaur/` (dashboard session tracking). It does not
|
|
70
|
+
yet inject `PI_SESSION_ID`; adding the `spawnHook` injector to that extension
|
|
71
|
+
is the remaining piece. See `platforms/pi/README.md`.
|
|
72
|
+
- **Live verification gate (cannot run here):** `echo $PI_SESSION_ID` from a Pi
|
|
73
|
+
tool call returns the real id.
|
|
74
|
+
|
|
75
|
+
## Cursor — best-effort nonce handshake (env injection impossible)
|
|
76
|
+
|
|
77
|
+
Cursor hooks deliver `conversation_id` on stdin and can only allow/deny — they
|
|
78
|
+
**cannot inject env**, and there is no `CURSOR_SESSION_ID` (open feature
|
|
79
|
+
request). So we key on a per-invocation **nonce**, not cwd (cwd is ambiguous
|
|
80
|
+
under co-tenancy):
|
|
81
|
+
|
|
82
|
+
1. The `syntaur` CLI emits a nonce token in its own argv.
|
|
83
|
+
2. A shipped `beforeShellExecution` hook (which knows `conversation_id`) writes
|
|
84
|
+
`nonce → conversation_id` into `~/.syntaur/runtime/cursor-nonces/<nonce>.json`.
|
|
85
|
+
3. The resolver's **layer-3 seam** (`resolveSideChannelSessionId` in
|
|
86
|
+
`src/utils/session-id.ts`) reads its own nonce back.
|
|
87
|
+
|
|
88
|
+
- **Status:** layer-3 seam exists and returns `undefined` (no-op) today; the
|
|
89
|
+
argv-nonce emission and the `beforeShellExecution` hook are the remaining
|
|
90
|
+
pieces. Documented design; reference hook in `platforms/cursor/hooks/`.
|
|
91
|
+
- **Live verification gate (cannot run here):** needs a live Cursor session.
|
|
92
|
+
|
|
93
|
+
## Codex — best-effort; exact requires a start hook (open question resolved)
|
|
94
|
+
|
|
95
|
+
**Open question (design memo #1): does Codex expose a session-start event and/or
|
|
96
|
+
the process pid to a hook?** Finding from this branch's wired Codex hooks
|
|
97
|
+
(`platforms/codex/hooks.json`):
|
|
98
|
+
|
|
99
|
+
- `PreToolUse` (`enforce-boundaries.sh`) stdin carries `.tool_name` /
|
|
100
|
+
`.tool_input` only — **no session id, no pid.**
|
|
101
|
+
- `SessionEnd` (`session-cleanup.sh`) stdin carries `.cwd` only — **no id.**
|
|
102
|
+
- **There is no SessionStart hook.**
|
|
103
|
+
|
|
104
|
+
So Codex exposes no real id to any currently-wired hook, and capture-at-birth
|
|
105
|
+
(stamping the generic marker) is **not possible today** without Codex either
|
|
106
|
+
adding a session-start event that surfaces the rollout id/pid, or surfacing an
|
|
107
|
+
id on an existing hook's stdin. If/when it does, an early hook can
|
|
108
|
+
`writeRuntimeMarker(<codexPid>, …)` and both the resolver (layer 4) and the
|
|
109
|
+
Codex cleanup hook will resolve it exactly.
|
|
110
|
+
|
|
111
|
+
**Honest floor today:**
|
|
112
|
+
- `session-cleanup.sh` no longer trusts the clobbered scalar; it resolves only
|
|
113
|
+
from an exact runtime marker and otherwise **skips** (the dashboard liveness
|
|
114
|
+
reaper marks the dead session stopped). It never mis-stops a co-tenant.
|
|
115
|
+
- For attribution that must be exact now, pass explicit `--session-id`
|
|
116
|
+
(sourced from `payload.id` of the matching `~/.codex/sessions/.../rollout-*.jsonl`,
|
|
117
|
+
as `platforms/codex/scripts/resolve-session.sh` already does on a cwd basis).
|
|
@@ -405,7 +405,7 @@ Created by `/grab-assignment` in the current working directory. The SessionStart
|
|
|
405
405
|
}
|
|
406
406
|
```
|
|
407
407
|
|
|
408
|
-
Read by `/plan-assignment`, `/complete-assignment`, and the write boundary hook to determine what the current agent is allowed to do.
|
|
408
|
+
Read by `/plan-assignment`, `/complete-assignment`, and the write boundary hook to determine what the current agent is allowed to do. Note that the `sessionId` scalar above is a shared, **legacy hint** — a co-tenant sharing the workspace can clobber it. The active session id is resolved from the running process (env `$CLAUDE_CODE_SESSION_ID` / the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`, else `syntaur session resolve-id`); the scalar is only a last-resort fallback, never authoritative.
|
|
409
409
|
|
|
410
410
|
---
|
|
411
411
|
|
|
@@ -33,11 +33,12 @@ Extract optional flags from the argument string:
|
|
|
33
33
|
|
|
34
34
|
### Step 2: Source the real session id + transcript path
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Resolve the session id from *your* running process, in priority order:
|
|
37
37
|
|
|
38
|
-
1.
|
|
39
|
-
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions
|
|
40
|
-
3.
|
|
38
|
+
1. `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`) if your runtime injects it.
|
|
39
|
+
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions/<pid>.json` whose `cwd` matches `$(pwd)` and use its `sessionId` field. The transcript path is conventionally `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; include it if the file exists, otherwise omit.
|
|
40
|
+
3. Only as a last resort, fall back to the `sessionId` scalar in `.syntaur/context.json` (and the companion `transcriptPath` if present). This scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber — never treat it as authoritative.
|
|
41
|
+
4. If no source yields an id, abort with: "Could not resolve a real Claude Code session id. Restart the Claude session so the SessionStart hook can populate `.syntaur/context.json`, or run `/rename <slug>` then try again."
|
|
41
42
|
|
|
42
43
|
DO NOT generate a UUID. `syntaur track-session` rejects missing session IDs.
|
|
43
44
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"hooks": [
|
|
18
18
|
{
|
|
19
19
|
"type": "prompt",
|
|
20
|
-
"prompt": "Before compaction, if there is an active Syntaur assignment (check `.syntaur/context.json` for `assignmentDir`
|
|
20
|
+
"prompt": "Before compaction, if there is an active Syntaur assignment (check `.syntaur/context.json` for `assignmentDir`), invoke `/save-session-summary` so a future session can resume cleanly. The summary is written under `<assignmentDir>/sessions/<sessionId>/`, where the CLI resolves `<sessionId>` from your running process (env / process tree) — do not read it from the `context.json` scalar (a co-tenant can clobber that hint). Skip silently if no active Syntaur context. Do NOT write to `handoff.md` — that is reserved for cross-ticket handoff at completion."
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
|
@@ -29,20 +29,28 @@ if [ ! -f "$CONTEXT_FILE" ]; then
|
|
|
29
29
|
exit 0
|
|
30
30
|
fi
|
|
31
31
|
|
|
32
|
-
# --- Step 4:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
32
|
+
# --- Step 4: Resolve the ENDING session's id ---
|
|
33
|
+
# Prefer the exact, per-process id from the SessionEnd stdin payload (Claude
|
|
34
|
+
# Code's contract passes .session_id). The context.json scalar is shared mutable
|
|
35
|
+
# state a co-tenant can clobber, so it is only a last-resort fallback — reading
|
|
36
|
+
# it first would mark the WRONG session stopped when two sessions share a
|
|
37
|
+
# workspace.
|
|
38
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
|
39
39
|
if [ -z "$SESSION_ID" ]; then
|
|
40
|
-
SESSION_ID=$(
|
|
40
|
+
SESSION_ID=$(jq -r '.sessionId // empty' "$CONTEXT_FILE" 2>/dev/null)
|
|
41
41
|
fi
|
|
42
|
+
MISSION_SLUG=$(jq -r '.projectSlug // empty' "$CONTEXT_FILE" 2>/dev/null)
|
|
43
|
+
ASSIGNMENT_SLUG=$(jq -r '.assignmentSlug // empty' "$CONTEXT_FILE" 2>/dev/null)
|
|
42
44
|
|
|
43
45
|
# No real session id available — exit quietly. We never synthesize one.
|
|
44
46
|
[ -z "$SESSION_ID" ] && exit 0
|
|
45
47
|
|
|
48
|
+
# Defensive: the id becomes a URL path segment — reject anything that isn't a
|
|
49
|
+
# plain id (UUID/ULID charset). Real Claude session ids never trip this.
|
|
50
|
+
case "$SESSION_ID" in
|
|
51
|
+
*[!A-Za-z0-9_-]*) exit 0 ;;
|
|
52
|
+
esac
|
|
53
|
+
|
|
46
54
|
# --- Dashboard endpoint resolution (mirror session-start.sh exactly so start
|
|
47
55
|
# and end hooks always target the same host:port) ---
|
|
48
56
|
PORT="${SYNTAUR_DASHBOARD_PORT:-}"
|
|
@@ -90,7 +90,7 @@ Do not delete the `.syntaur/` directory itself — other tooling may use it.
|
|
|
90
90
|
|
|
91
91
|
## Step 5: Close Session (optional)
|
|
92
92
|
|
|
93
|
-
If the
|
|
93
|
+
If the Syntaur dashboard is running, mark this session as cleared so the dashboard does not keep showing it as active. Resolve `<session-id>` from *your* running process — prefer `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`), otherwise run `syntaur session resolve-id`. Only if neither yields an id, fall back to the `sessionId` you captured from the original `context.json` in Step 1 (before deletion) — that scalar is a shared, legacy hint a co-tenant can clobber, so don't treat it as authoritative:
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
96
|
curl -s -X PATCH "http://localhost:$(cat ~/.syntaur/dashboard-port 2>/dev/null || echo 4800)/api/agent-sessions/<session-id>/status" \
|
|
@@ -101,7 +101,7 @@ Do NOT uncheck or rewrite superseded todo lines matching `- [x] ~~...~~ (superse
|
|
|
101
101
|
|
|
102
102
|
## Step 6: Close Session (optional)
|
|
103
103
|
|
|
104
|
-
If `.syntaur/context.json`
|
|
104
|
+
If the Syntaur dashboard is running, mark this session as completed. Resolve `<session-id>` from *your* running process — prefer `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`), otherwise run `syntaur session resolve-id`; fall back to the `sessionId` scalar in `.syntaur/context.json` only as a last resort (it is a shared, legacy hint a co-tenant can clobber, not authoritative):
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
107
|
curl -s -X PATCH "http://localhost:$(cat ~/.syntaur/dashboard-port 2>/dev/null || echo 4800)/api/agent-sessions/<session-id>/status" \
|
|
@@ -113,14 +113,15 @@ Use absolute paths (expand `~` to the home directory). If `workspace.repository`
|
|
|
113
113
|
|
|
114
114
|
## Step 6: Register Agent Session (real IDs only — no UUIDs)
|
|
115
115
|
|
|
116
|
-
`syntaur track-session` requires a `--session-id` from the agent runtime. Synthetic UUIDs are rejected. Source the real id in this order:
|
|
116
|
+
`syntaur track-session` requires a `--session-id` from the agent runtime. Synthetic UUIDs are rejected. Source the real per-process id in this order:
|
|
117
117
|
|
|
118
|
-
1.
|
|
118
|
+
1. Prefer the env var your runtime injects: `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`).
|
|
119
119
|
2. Otherwise, fall back to the per-agent lookup:
|
|
120
|
-
- **Claude Code**: the most-recently-modified `~/.claude/sessions
|
|
120
|
+
- **Claude Code**: the most-recently-modified `~/.claude/sessions/<pid>.json` whose `cwd` matches `$(pwd)` — read its `sessionId`. The transcript path is `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl` (omit if the file is absent).
|
|
121
121
|
- **Codex**: the most-recently-modified file under `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` whose first-line `session_meta.payload.cwd` matches `$(pwd)`. Use `payload.id` as the session id and the full rollout path as the transcript path.
|
|
122
122
|
- **Other agents**: use whatever real session identifier the runtime exposes. Do not invent one.
|
|
123
|
-
3.
|
|
123
|
+
3. Only as a last resort, fall back to the `sessionId` scalar already in `.syntaur/context.json` (and the companion `transcriptPath` if present). That scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber — never treat it as authoritative.
|
|
124
|
+
4. If no real id can be resolved, stop and tell the user to restart the session so the platform hook can populate it, or to run `/rename <assignment-slug>` (Claude Code) and retry.
|
|
124
125
|
|
|
125
126
|
After resolving, merge `sessionId` + `transcriptPath` back into context.json. Then register:
|
|
126
127
|
|
|
@@ -32,9 +32,14 @@ If the file does not exist, tell the user: "No active assignment found. Run `gra
|
|
|
32
32
|
|
|
33
33
|
Extract:
|
|
34
34
|
- `assignmentDir` (absolute path) — required.
|
|
35
|
-
- `sessionId` — required, must be the real agent runtime session id.
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
**Do not read the session id from `context.json` for identity.** That scalar is
|
|
37
|
+
a shared, legacy hint a co-tenant can clobber. The session id is resolved from
|
|
38
|
+
*your* running process — prefer, in order:
|
|
39
|
+
1. `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`) if your runtime injects it.
|
|
40
|
+
2. Otherwise omit `--session-id` entirely and let `syntaur session save` resolve it (it walks env → process tree → transcript, and falls back to the `context.json` hint only as a last resort).
|
|
41
|
+
|
|
42
|
+
Never invent or generate a session id.
|
|
38
43
|
|
|
39
44
|
## Step 2: Author the summary body
|
|
40
45
|
|
|
@@ -75,13 +80,17 @@ Pass the body to `syntaur session save` (it owns the directory, frontmatter,
|
|
|
75
80
|
and `created`-preservation):
|
|
76
81
|
|
|
77
82
|
```bash
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
# Recommended: omit --session-id and let the CLI resolve YOUR own session id.
|
|
84
|
+
printf '%s' "$BODY" | syntaur session save
|
|
85
|
+
# or from a file: syntaur session save --from-file <body.md>
|
|
86
|
+
# Pass --session-id <id> only to override (e.g. you already have $CLAUDE_CODE_SESSION_ID).
|
|
80
87
|
```
|
|
81
88
|
|
|
82
89
|
Resolves the active assignment from `.syntaur/context.json` (or pass
|
|
83
|
-
`--assignment <slug> [--project <slug>]`)
|
|
84
|
-
|
|
90
|
+
`--assignment <slug> [--project <slug>]`). `--session-id` now defaults to the
|
|
91
|
+
**resolved** session (env → process tree → transcript), falling back to the
|
|
92
|
+
`context.json` hint only as a last resort — so a co-tenant that clobbered the
|
|
93
|
+
shared scalar can't make you write under the wrong id. The command:
|
|
85
94
|
|
|
86
95
|
- Creates `<assignmentDir>/sessions/<sessionId>/` (idempotent) and writes
|
|
87
96
|
`summary.md` — a **single document per session id**, overwritten in place;
|
|
@@ -29,11 +29,12 @@ Extract optional flags from the argument string:
|
|
|
29
29
|
|
|
30
30
|
### Step 2: Source the real session id + transcript path
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Resolve the session id from *your* running process, in priority order:
|
|
33
33
|
|
|
34
|
-
1.
|
|
35
|
-
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions
|
|
36
|
-
3.
|
|
34
|
+
1. `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`) if your runtime injects it.
|
|
35
|
+
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions/<pid>.json` whose `cwd` matches `$(pwd)` and use its `sessionId` field. The transcript path is conventionally `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; include it if the file exists, otherwise omit.
|
|
36
|
+
3. Only as a last resort, fall back to the `sessionId` scalar in `.syntaur/context.json` (and the companion `transcriptPath` if present). This scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber — never treat it as authoritative.
|
|
37
|
+
4. If no source yields an id, abort with: "Could not resolve a real Claude Code session id. Restart the Claude session so the SessionStart hook can populate `.syntaur/context.json`, or run `/rename <slug>` then try again."
|
|
37
38
|
|
|
38
39
|
DO NOT generate a UUID. `syntaur track-session` rejects missing session IDs.
|
|
39
40
|
|
|
@@ -133,7 +133,7 @@ Use these commands directly when needed:
|
|
|
133
133
|
2. Update any missing checkboxes in `assignment.md`.
|
|
134
134
|
3. Append a final timestamped entry to `progress.md` summarizing the work.
|
|
135
135
|
4. Append a new structured handoff entry to `handoff.md`.
|
|
136
|
-
5. Mark the dashboard session completed
|
|
136
|
+
5. Mark the dashboard session completed. Resolve the session id from your running process (prefer `$CLAUDE_CODE_SESSION_ID` / the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`, otherwise `syntaur session resolve-id`); the `sessionId` scalar in `.syntaur/context.json` is only a clobberable legacy-hint fallback, not authoritative.
|
|
137
137
|
6. Transition the assignment with `syntaur review` or `syntaur complete`.
|
|
138
138
|
7. Remove `.syntaur/context.json` when the assignment is no longer active.
|
|
139
139
|
|
|
@@ -16,7 +16,7 @@ Codex has no `PreCompact` hook event — invoke this command manually.
|
|
|
16
16
|
|
|
17
17
|
Follow the `save-session-summary` skill in full. Summary:
|
|
18
18
|
|
|
19
|
-
1. Read `.syntaur/context.json`. Required: `assignmentDir
|
|
19
|
+
1. Read `.syntaur/context.json`. Required: `assignmentDir`. Do NOT read the session id from this file for identity — that scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber. Resolve the session id from *your* running process: prefer `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`), otherwise omit `--session-id` and let `syntaur session save` resolve it (env → process tree → transcript, falling back to the context.json hint only as a last resort). Do not synthesize a session id.
|
|
20
20
|
2. Create `<assignmentDir>/sessions/<sessionId>/` only at write time (avoid empty dirs).
|
|
21
21
|
3. Write or overwrite `<assignmentDir>/sessions/<sessionId>/summary.md` with frontmatter (`assignment`, `sessionId`, `created`, `updated`) and body sections: `## Snapshot`, `## What Was Done`, `## What's Next`, `## Open Questions`, `## Load-Bearing Context`. Single document per session id — directory partitions by session.
|
|
22
22
|
4. Do NOT modify `handoff.md`.
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Syntaur SessionEnd hook for Codex plugins.
|
|
3
|
-
# Marks
|
|
3
|
+
# Marks the ENDING agent session as stopped when a Codex session exits.
|
|
4
|
+
#
|
|
5
|
+
# Identity rule: resolve the ending session's id EXACTLY, from a capture-at-birth
|
|
6
|
+
# runtime marker stamped by a session-start/boundary hook (keyed by the Codex
|
|
7
|
+
# process pid). We deliberately do NOT read .sessionId from context.json — that
|
|
8
|
+
# shared scalar is clobbered when two sessions share a workspace, so trusting it
|
|
9
|
+
# would mark the WRONG session stopped. Codex has no SessionStart hook and its
|
|
10
|
+
# SessionEnd stdin carries no id, so when no exact marker resolves we SKIP the
|
|
11
|
+
# PATCH and let the dashboard liveness reaper mark the dead session stopped.
|
|
4
12
|
|
|
5
13
|
set -o pipefail 2>/dev/null || true
|
|
6
14
|
|
|
@@ -23,17 +31,66 @@ if [ ! -f "$CONTEXT_FILE" ]; then
|
|
|
23
31
|
exit 0
|
|
24
32
|
fi
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
RUNTIME_DIR="${SYNTAUR_RUNTIME_SESSIONS_DIR:-$HOME/.syntaur/runtime/sessions}"
|
|
35
|
+
|
|
36
|
+
# Walk the ancestor-pid chain reading runtime markers. Returns the nearest
|
|
37
|
+
# marker's sessionId on stdout (and success), pid-reuse-guarded by procStart.
|
|
38
|
+
resolve_session_from_markers() {
|
|
39
|
+
local pid="$PPID"
|
|
40
|
+
local depth=0
|
|
41
|
+
while [ "$depth" -lt 12 ]; do
|
|
42
|
+
case "$pid" in
|
|
43
|
+
'' | *[!0-9]*) break ;;
|
|
44
|
+
esac
|
|
45
|
+
[ "$pid" -le 1 ] && break
|
|
46
|
+
local marker="$RUNTIME_DIR/$pid.json"
|
|
47
|
+
if [ -f "$marker" ]; then
|
|
48
|
+
local sid procstart actual
|
|
49
|
+
sid=$(jq -r '.sessionId // empty' "$marker" 2>/dev/null)
|
|
50
|
+
procstart=$(jq -r '.procStart // empty' "$marker" 2>/dev/null)
|
|
51
|
+
if [ -n "$sid" ]; then
|
|
52
|
+
if [ -n "$procstart" ]; then
|
|
53
|
+
# Fail CLOSED: require a readable, exactly-matching live start time;
|
|
54
|
+
# if ps can't prove the pid wasn't recycled, skip this marker.
|
|
55
|
+
actual=$(ps -o lstart= -p "$pid" 2>/dev/null | sed 's/^ *//;s/ *$//')
|
|
56
|
+
if [ -n "$actual" ] && [ "$actual" = "$procstart" ]; then
|
|
57
|
+
printf '%s' "$sid"
|
|
58
|
+
return 0
|
|
59
|
+
fi
|
|
60
|
+
# else: cannot prove pid identity — skip this marker, keep walking
|
|
61
|
+
else
|
|
62
|
+
printf '%s' "$sid"
|
|
63
|
+
return 0
|
|
64
|
+
fi
|
|
65
|
+
fi
|
|
66
|
+
fi
|
|
67
|
+
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
|
|
68
|
+
depth=$((depth + 1))
|
|
69
|
+
done
|
|
70
|
+
return 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
SESSION_ID=$(resolve_session_from_markers || true)
|
|
27
74
|
MISSION_SLUG=$(jq -r '.projectSlug // empty' "$CONTEXT_FILE" 2>/dev/null)
|
|
28
75
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
# No EXACT id — do not risk stopping the wrong co-tenant session.
|
|
77
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
78
|
+
|
|
79
|
+
# Defensive: the id becomes a URL path segment — reject anything that isn't a
|
|
80
|
+
# plain id (UUID/ULID charset). Real ids never trip this.
|
|
81
|
+
case "$SESSION_ID" in
|
|
82
|
+
*[!A-Za-z0-9_-]*) exit 0 ;;
|
|
83
|
+
esac
|
|
32
84
|
|
|
33
85
|
PORT=$(cat "$HOME/.syntaur/dashboard-port" 2>/dev/null || echo "4800")
|
|
86
|
+
if [ -n "$MISSION_SLUG" ]; then
|
|
87
|
+
BODY="{\"status\": \"stopped\", \"projectSlug\": \"${MISSION_SLUG}\"}"
|
|
88
|
+
else
|
|
89
|
+
BODY="{\"status\": \"stopped\"}"
|
|
90
|
+
fi
|
|
34
91
|
curl -sf -X PATCH "http://localhost:${PORT}/api/agent-sessions/${SESSION_ID}/status" \
|
|
35
92
|
-H "Content-Type: application/json" \
|
|
36
|
-
-d "
|
|
93
|
+
-d "$BODY" \
|
|
37
94
|
-o /dev/null 2>/dev/null || true
|
|
38
95
|
|
|
39
96
|
exit 0
|
|
@@ -90,7 +90,7 @@ Do not delete the `.syntaur/` directory itself — other tooling may use it.
|
|
|
90
90
|
|
|
91
91
|
## Step 5: Close Session (optional)
|
|
92
92
|
|
|
93
|
-
If the
|
|
93
|
+
If the Syntaur dashboard is running, mark this session as cleared so the dashboard does not keep showing it as active. Resolve `<session-id>` from *your* running process — prefer `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`), otherwise run `syntaur session resolve-id`. Only if neither yields an id, fall back to the `sessionId` you captured from the original `context.json` in Step 1 (before deletion) — that scalar is a shared, legacy hint a co-tenant can clobber, so don't treat it as authoritative:
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
96
|
curl -s -X PATCH "http://localhost:$(cat ~/.syntaur/dashboard-port 2>/dev/null || echo 4800)/api/agent-sessions/<session-id>/status" \
|
|
@@ -101,7 +101,7 @@ Do NOT uncheck or rewrite superseded todo lines matching `- [x] ~~...~~ (superse
|
|
|
101
101
|
|
|
102
102
|
## Step 6: Close Session (optional)
|
|
103
103
|
|
|
104
|
-
If `.syntaur/context.json`
|
|
104
|
+
If the Syntaur dashboard is running, mark this session as completed. Resolve `<session-id>` from *your* running process — prefer `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`), otherwise run `syntaur session resolve-id`; fall back to the `sessionId` scalar in `.syntaur/context.json` only as a last resort (it is a shared, legacy hint a co-tenant can clobber, not authoritative):
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
107
|
curl -s -X PATCH "http://localhost:$(cat ~/.syntaur/dashboard-port 2>/dev/null || echo 4800)/api/agent-sessions/<session-id>/status" \
|
|
@@ -113,14 +113,15 @@ Use absolute paths (expand `~` to the home directory). If `workspace.repository`
|
|
|
113
113
|
|
|
114
114
|
## Step 6: Register Agent Session (real IDs only — no UUIDs)
|
|
115
115
|
|
|
116
|
-
`syntaur track-session` requires a `--session-id` from the agent runtime. Synthetic UUIDs are rejected. Source the real id in this order:
|
|
116
|
+
`syntaur track-session` requires a `--session-id` from the agent runtime. Synthetic UUIDs are rejected. Source the real per-process id in this order:
|
|
117
117
|
|
|
118
|
-
1.
|
|
118
|
+
1. Prefer the env var your runtime injects: `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`).
|
|
119
119
|
2. Otherwise, fall back to the per-agent lookup:
|
|
120
|
-
- **Claude Code**: the most-recently-modified `~/.claude/sessions
|
|
120
|
+
- **Claude Code**: the most-recently-modified `~/.claude/sessions/<pid>.json` whose `cwd` matches `$(pwd)` — read its `sessionId`. The transcript path is `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl` (omit if the file is absent).
|
|
121
121
|
- **Codex**: the most-recently-modified file under `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` whose first-line `session_meta.payload.cwd` matches `$(pwd)`. Use `payload.id` as the session id and the full rollout path as the transcript path.
|
|
122
122
|
- **Other agents**: use whatever real session identifier the runtime exposes. Do not invent one.
|
|
123
|
-
3.
|
|
123
|
+
3. Only as a last resort, fall back to the `sessionId` scalar already in `.syntaur/context.json` (and the companion `transcriptPath` if present). That scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber — never treat it as authoritative.
|
|
124
|
+
4. If no real id can be resolved, stop and tell the user to restart the session so the platform hook can populate it, or to run `/rename <assignment-slug>` (Claude Code) and retry.
|
|
124
125
|
|
|
125
126
|
After resolving, merge `sessionId` + `transcriptPath` back into context.json. Then register:
|
|
126
127
|
|
|
@@ -32,9 +32,14 @@ If the file does not exist, tell the user: "No active assignment found. Run `gra
|
|
|
32
32
|
|
|
33
33
|
Extract:
|
|
34
34
|
- `assignmentDir` (absolute path) — required.
|
|
35
|
-
- `sessionId` — required, must be the real agent runtime session id.
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
**Do not read the session id from `context.json` for identity.** That scalar is
|
|
37
|
+
a shared, legacy hint a co-tenant can clobber. The session id is resolved from
|
|
38
|
+
*your* running process — prefer, in order:
|
|
39
|
+
1. `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`) if your runtime injects it.
|
|
40
|
+
2. Otherwise omit `--session-id` entirely and let `syntaur session save` resolve it (it walks env → process tree → transcript, and falls back to the `context.json` hint only as a last resort).
|
|
41
|
+
|
|
42
|
+
Never invent or generate a session id.
|
|
38
43
|
|
|
39
44
|
## Step 2: Author the summary body
|
|
40
45
|
|
|
@@ -75,13 +80,17 @@ Pass the body to `syntaur session save` (it owns the directory, frontmatter,
|
|
|
75
80
|
and `created`-preservation):
|
|
76
81
|
|
|
77
82
|
```bash
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
# Recommended: omit --session-id and let the CLI resolve YOUR own session id.
|
|
84
|
+
printf '%s' "$BODY" | syntaur session save
|
|
85
|
+
# or from a file: syntaur session save --from-file <body.md>
|
|
86
|
+
# Pass --session-id <id> only to override (e.g. you already have $CLAUDE_CODE_SESSION_ID).
|
|
80
87
|
```
|
|
81
88
|
|
|
82
89
|
Resolves the active assignment from `.syntaur/context.json` (or pass
|
|
83
|
-
`--assignment <slug> [--project <slug>]`)
|
|
84
|
-
|
|
90
|
+
`--assignment <slug> [--project <slug>]`). `--session-id` now defaults to the
|
|
91
|
+
**resolved** session (env → process tree → transcript), falling back to the
|
|
92
|
+
`context.json` hint only as a last resort — so a co-tenant that clobbered the
|
|
93
|
+
shared scalar can't make you write under the wrong id. The command:
|
|
85
94
|
|
|
86
95
|
- Creates `<assignmentDir>/sessions/<sessionId>/` (idempotent) and writes
|
|
87
96
|
`summary.md` — a **single document per session id**, overwritten in place;
|
|
@@ -29,11 +29,12 @@ Extract optional flags from the argument string:
|
|
|
29
29
|
|
|
30
30
|
### Step 2: Source the real session id + transcript path
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Resolve the session id from *your* running process, in priority order:
|
|
33
33
|
|
|
34
|
-
1.
|
|
35
|
-
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions
|
|
36
|
-
3.
|
|
34
|
+
1. `$CLAUDE_CODE_SESSION_ID` (or the peer `OPENCODE_SESSION_ID` / `PI_SESSION_ID`) if your runtime injects it.
|
|
35
|
+
2. Otherwise, read the most-recently-modified file under `~/.claude/sessions/<pid>.json` whose `cwd` matches `$(pwd)` and use its `sessionId` field. The transcript path is conventionally `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; include it if the file exists, otherwise omit.
|
|
36
|
+
3. Only as a last resort, fall back to the `sessionId` scalar in `.syntaur/context.json` (and the companion `transcriptPath` if present). This scalar is a shared, legacy hint a co-tenant sharing this workspace can clobber — never treat it as authoritative.
|
|
37
|
+
4. If no source yields an id, abort with: "Could not resolve a real Claude Code session id. Restart the Claude session so the SessionStart hook can populate `.syntaur/context.json`, or run `/rename <slug>` then try again."
|
|
37
38
|
|
|
38
39
|
DO NOT generate a UUID. `syntaur track-session` rejects missing session IDs.
|
|
39
40
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Cursor session-id handshake (Reference Only)
|
|
2
|
+
|
|
3
|
+
Cursor hooks deliver `conversation_id` on stdin but **cannot inject environment
|
|
4
|
+
variables** into spawned commands (they can only allow/deny), and there is no
|
|
5
|
+
`CURSOR_SESSION_ID` env (open feature request). So the env-var path (layer 2)
|
|
6
|
+
is impossible for Cursor. Instead we use a per-invocation **nonce** — keying on
|
|
7
|
+
a nonce, not cwd, is what keeps it co-tenant-safe.
|
|
8
|
+
|
|
9
|
+
> **Status:** reference design. The resolver's layer-3 seam
|
|
10
|
+
> (`resolveSideChannelSessionId` in `src/utils/session-id.ts`) is in place and
|
|
11
|
+
> returns `undefined` today. The two pieces below complete the handshake.
|
|
12
|
+
|
|
13
|
+
## The handshake
|
|
14
|
+
|
|
15
|
+
1. **CLI emits a nonce in its own argv.** A `syntaur` invocation that needs to
|
|
16
|
+
self-identify passes e.g. `--session-nonce <random>` (the nonce travels in
|
|
17
|
+
the command line the Cursor hook can see).
|
|
18
|
+
2. **`beforeShellExecution` hook records the mapping.** A shipped hook that
|
|
19
|
+
receives `conversation_id` on stdin and sees the spawned command's argv
|
|
20
|
+
extracts the nonce and writes:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
~/.syntaur/runtime/cursor-nonces/<nonce>.json = { "sessionId": "<conversation_id>" }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
#!/usr/bin/env bash
|
|
28
|
+
# beforeShellExecution — reference. Reads the hook payload on stdin.
|
|
29
|
+
payload=$(cat)
|
|
30
|
+
cid=$(printf '%s' "$payload" | jq -r '.conversation_id // empty')
|
|
31
|
+
cmd=$(printf '%s' "$payload" | jq -r '.command // .tool_input.command // empty')
|
|
32
|
+
nonce=$(printf '%s' "$cmd" | sed -n 's/.*--session-nonce \([A-Za-z0-9_-]\{8,\}\).*/\1/p')
|
|
33
|
+
if [ -n "$cid" ] && [ -n "$nonce" ]; then
|
|
34
|
+
dir="$HOME/.syntaur/runtime/cursor-nonces"
|
|
35
|
+
mkdir -p "$dir"
|
|
36
|
+
printf '{"sessionId":"%s"}' "$cid" > "$dir/$nonce.json"
|
|
37
|
+
fi
|
|
38
|
+
echo '{"permission":"allow"}' # never block on this hook
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
3. **Resolver layer 3 reads its own nonce back.** When the CLI was invoked with
|
|
42
|
+
`--session-nonce <n>`, `resolveSideChannelSessionId` reads
|
|
43
|
+
`~/.syntaur/runtime/cursor-nonces/<n>.json` and returns its `sessionId`.
|
|
44
|
+
|
|
45
|
+
## Verification (needs a live Cursor session — cannot run in CI)
|
|
46
|
+
|
|
47
|
+
Run a `syntaur` command from a Cursor agent shell with `--session-nonce <n>`;
|
|
48
|
+
confirm `~/.syntaur/runtime/cursor-nonces/<n>.json` is written with the right
|
|
49
|
+
`conversation_id` and that the command attributes to it.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntaur OpenCode plugin — inject OPENCODE_SESSION_ID into every spawned
|
|
3
|
+
* command so `syntaur` (and any other tool) can resolve the caller's OWN
|
|
4
|
+
* session id from the process (layer 2 of resolveOwnSessionId), instead of the
|
|
5
|
+
* co-tenant-clobberable .syntaur/context.json scalar.
|
|
6
|
+
*
|
|
7
|
+
* REFERENCE ARTIFACT. Syntaur's current OpenCode integration is adapter-file
|
|
8
|
+
* only (see ../README.md); this is not yet auto-installed. To use:
|
|
9
|
+
* 1. Drop this file at `~/.config/opencode/plugin/syntaur-session-env.js`
|
|
10
|
+
* (or your project's `.opencode/plugin/`).
|
|
11
|
+
* 2. Restart OpenCode.
|
|
12
|
+
*
|
|
13
|
+
* Verify (needs a live OpenCode build): from a tool call, `echo $OPENCODE_SESSION_ID`
|
|
14
|
+
* prints the conversation/session id.
|
|
15
|
+
*
|
|
16
|
+
* Caveat: the V2 `core` bash tool is not yet wired to `shell.env`
|
|
17
|
+
* (`// TODO` in packages/core/src/tool/bash.ts); the `opencode` ShellTool path
|
|
18
|
+
* is. Verify against your target OpenCode version. The hook signature below
|
|
19
|
+
* follows the @opencode-ai/plugin `shell.env` trigger — adjust if the API shifts.
|
|
20
|
+
*/
|
|
21
|
+
export const SyntaurSessionEnv = async () => ({
|
|
22
|
+
// Fired per spawn with the active session id; mutate the child env in place.
|
|
23
|
+
'shell.env': async ({ sessionID }, output) => {
|
|
24
|
+
if (sessionID && output && output.env) {
|
|
25
|
+
output.env.OPENCODE_SESSION_ID = sessionID;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export default SyntaurSessionEnv;
|