{
  "summary": "## Finding\n\nThe duplicate startup records are produced by **two distinct registration-capable startup paths in the extension**, not by two OMP `session_start` events:\n\n1. `pi.on(\"session_start\", …)` explicitly runs `spt api --adapter omp-spt bind <id> --set-session-id <actual OMP UUID>`.\n2. After that bind and initial `state idle`, `startListener()` spawns bare `spt ready <id>`. The public CLI defines `ready` as **“Become reachable: register the perch and listen (blocks)”**. It carries no `--adapter`, `--session-id`, or `--token`, so it does not preserve the already-bound OMP identity in its argv. This is the strongest adapter-side explanation for the second synthetic `sess-<pid>-<epoch>` boot record.\n\nThe observed chronology supplied by the reproduction investigation is consistent with that sequence: the correct OMP UUID row appears at/just before the synthetic row; all observed rows have `adapter=\"omp-spt\"`. The synthetic row has `trigger=\"boot\"`.\n\n## Important correction: `boot` is not the adapter label\n\n`boot` cannot be produced as the adapter label by this repository’s explicit calls:\n\n- `adapter/strings/omp-spt.mjs:1-3` hard-codes `const ADAPTER = \"omp-spt\"`.\n- `adapter/omp-spt.toml:9-17` declares `[adapter].name = \"omp-spt\"`.\n- Bind/state/session-end calls all pass `--adapter`, using that constant.\n- In the observed newline-JSON ledger rows, `boot` is the `trigger` field; `adapter` remains `omp-spt`.\n\nTherefore, if any consumer renders `boot` under an “adapter” column, that is a field-mapping/display defect outside this repository, or a human reading of the trigger as the label. The extension never supplies the literal string `boot`. The only adapter-unpinned startup call is bare `spt ready`; public resolution may choose a registered adapter, but it has no path that turns the lifecycle trigger `boot` into an adapter name.\n\n## Ranked root-cause evidence\n\n### 1. Bare `spt ready` re-registers after explicit bind — HIGH confidence\n\n- `adapter/strings/omp-spt.mjs:1023-1035`: OMP `session_start` obtains the real OMP session ID and completes an explicit `api bind` using `--adapter omp-spt` and `--set-session-id <sid>`.\n- `adapter/strings/omp-spt.mjs:1050-1057`: after bind it synchronizes the initial state and then calls `startListener()`.\n- `adapter/strings/omp-spt.mjs:926-937`: `startListener()` launches `spt ready <id> [--subnet …]` with no bound session ID, token, or explicit adapter.\n- Public SPT CLI reference: [`spt ready`](https://sabermage.github.io/spt-releases/cli/reference.html#spt-ready) says it “register[s] the perch and listen[s] (blocks).” Its documented options are only `--once` and `--subnet`; it has no identity-preserving `--session-id`, `--token`, or `--adapter` option.\n- Public SPT startup contract: [`api bind`](https://sabermage.github.io/spt-releases/harness-contract/api.html#api-bind-id---set-session-id-sid) is the spt-hosted registration path; [`ready`/`listen`](https://sabermage.github.io/spt-releases/harness-contract/api.html#the-two-startup-flows) belongs to the separately registering seed/listen flow. The extension currently composes both startup flows.\n- `tests/omp-extension.mjs:297-324` locks this composition in as expected behavior: first bind, then state idle, then a child with argv `ready omp-agent --subnet mesh-a`.\n- Reproduction evidence: one correct UUID boot row followed by one synthetic `sess-…` boot row is the expected signature of bind first, then a second registration path lacking the OMP UUID.\n\n### 2. `endpoint run`/post-spawn orchestration creates a preliminary identity before bind — MEDIUM/alternative\n\n- `adapter/omp-spt.toml:41-44` declares `session_id_source = \"post_spawn\"`, so the real OMP session ID is not known until OMP starts.\n- `adapter/omp-spt.toml:69-81` gives spt-core fresh/resume launch templates; public SPT docs say endpoint identity precedes the post-spawn session and `bind` records the discovered session ID.\n- A synthetic pre-bind identity is therefore plausible in core orchestration, but the available public docs do not say endpoint-run should append a session-ledger row before `bind`, and the observed correct-row-then-synthetic-row ordering points more strongly at the later bare `ready` spawn.\n- This alternative can be separated deterministically by exercising only the extension harness: if bind + bare ready models two registrations without running endpoint-run, endpoint-run is exonerated.\n\n### 3. Duplicate OMP `session_start` emission — LOW confidence; does not match the evidence\n\n- The handler has no one-shot/idempotence guard. If a test calls `harness.emit(\"session_start\")` twice, it will execute two binds and start two listeners (`adapter/strings/omp-spt.mjs:1023-1066`). This is a real latent robustness gap.\n- However, OMP’s public event type is only `{ type: \"session_start\" }` and is documented as fired on the **initial session load**: [`shared-events.ts:27-30`](https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/extensibility/shared-events.ts). The event contains no fresh/resume/boot source field.\n- The observed pair contains one real UUID and one synthetic `sess-…` ID. Two invocations of the same handler would both read `ctx.sessionManager.getSessionId()` and bind the same real UUID, so duplicate event emission does not explain the actual row shapes.\n\n### 4. `state idle` — ruled out as a registration source by the public contract\n\n- `adapter/strings/omp-spt.mjs:573-590` serializes state changes and calls `api --adapter omp-spt state <state> <id> --token <bind-token>`.\n- Startup calls it only after bind completes (`:1034-1051`).\n- The public API defines `state` as activity reporting and echo-gate arming, not endpoint/session registration. It already authenticates against the bound perch with the returned token.\n- Later busy/idle paths at `:855-864`, `:868-881`, `:1091-1122`, and `:1126-1135` reuse the same function and cannot originate a second startup session identity.\n\n### 5. Psyche lifecycle — ruled out\n\n- `adapter/omp-spt.toml:49-65` declares bounded `psyche-omp` roles separate from `[session.self]`/`[session.resume]`.\n- `tools/omp-spt/src/psyche_omp.rs:94-122` constructs headless OMP argv containing `--no-extensions`; `:271-284` spawns OMP with those argv.\n- Consequently the Psyche cannot load `strings/omp-spt.mjs`, cannot receive this extension’s `session_start`, and contains no `spt bind`, `ready`, `state`, or session-history call. Its private transcripts live under the mind file’s sibling `omp-sessions` directory (`psyche_omp.rs:78-94`).\n- The same extension suppression exists for echo-commune (`tools/omp-spt/src/echo_commune_omp.rs:202-216`).\n\n## Fresh, resume, switch, and shutdown behavior\n\n- **Fresh native startup:** `[session.self]` (`adapter/omp-spt.toml:69-74`) runs `launch-omp`; `tools/omp-spt/src/launch_omp.rs:47-59` forwards only `--extension <omp-spt.mjs>`. OMP emits one initial `session_start`; the extension binds the UUID, publishes idle, then starts the bare ready listener.\n- **Native resume:** `[session.resume]` (`adapter/omp-spt.toml:76-81`) uses the same launch shim with `--resume`; `launch_omp.rs:47-59` maps that to `-r <session-id>` before the same `--extension`. The same single public `session_start` handler runs. There is no distinct OMP “resume start” event payload for the extension to branch on.\n- **In-TUI new/resume/fork/handoff:** blocked before the session changes by `session_before_switch` (`omp-spt.mjs:1068-1079`); branching is blocked at `:1080-1082`. The tests cover all switch reasons at `tests/omp-extension.mjs:326-337`. These paths do not register.\n- **Shutdown:** `session_shutdown` (`omp-spt.mjs:1149-1153`) enters bounded teardown; `endSession()` at `:592-603` issues authenticated `session-end`. It preserves history by contract but does not create a startup row.\n\n## Strongest existing deterministic test seam\n\n`tests/omp-extension.mjs:createHarness` (`:99-197`) is the strongest existing seam because it controls both sides that matter:\n\n- `runSptCommand` (`:139-149`) captures the explicit bind/state/session-end calls and can return a deterministic bind token.\n- `spawnProcess` (`:151-159`) captures the long-running listener child and its exact argv.\n- `emit()` (`:183-191`) deterministically fires the real registered OMP event handler.\n- `testLifecycleCustodyAndContext` (`:280-324`) already asserts the complete startup command order and currently confirms the problematic bare `ready` command.\n- `testDeferredBindLifecycleSerialization` (`:415-488`) proves the listener does not start before bind completes, making the order deterministic rather than race-dependent.\n\nThe Rust `launch_omp` integration test (`tools/omp-spt/tests/launch_omp.rs:150-184, 325-354`) is valuable for proving fresh/resume argv, but its fake OMP only records argv and exits; it never loads the extension or invokes SPT, so it cannot reproduce duplicate registration. The real native acceptance in `docs/CI.md:44-50` is the end-to-end confirmation seam, but no documented scriptable public CLI exposes the session-ledger rows; the interactive resume picker is the apparent public reader. That makes the JS harness the best deterministic regression seam under the public-surface-only constraint.\n\n## Proposed failing test behavior\n\nAdd a focused startup-identity test beside `testLifecycleCustodyAndContext`, using the existing `createHarness` seam:\n\n1. Create a harness with `sessionId: \"omp-uuid-1\"` and endpoint `omp-agent`.\n2. Emit exactly one `session_start`.\n3. Classify every startup command with registration semantics:\n   - explicit `api bind` is one registration;\n   - bare top-level `ready` is also registration-capable per the public CLI contract.\n4. Assert there is exactly **one boot registration identity**, `{ adapter: \"omp-spt\", sessionId: \"omp-uuid-1\" }`.\n5. Assert the listener attachment preserves the already-bound identity in its argv and cannot fall back to a synthetic session. The important negative assertion is: **no child may be spawned as bare `spt ready <id>` after bind**.\n6. Run the same assertion with a resume-shaped session ID to prove fresh/resume parity.\n\nCurrent expected failure: the captured startup contains the correct bind and then `children[0].args === [\"ready\", \"omp-agent\", …]`, so the test reports two registration-capable paths and identifies the second as lacking the OMP UUID/adapter proof.\n\nA secondary idempotence test should emit `session_start` twice and assert one bind/listener. It will currently fail with two binds/listeners, but it is defense-in-depth; it is not the primary reproduction because it would yield duplicate real UUIDs rather than the observed real-plus-synthetic pair.\n\nNo files were edited and no tests were run, per instruction.",
  "files": [
    {
      "path": "adapter/strings/omp-spt.mjs",
      "description": "Primary cause. `ADAPTER` is fixed at lines 1-3; `setState` is activity-only at 573-590; `startListener` spawns registration-capable bare `spt ready` at 926-937; `session_start` performs explicit post-spawn bind then state/listener at 1023-1066; switch/branch guards and shutdown are at 1068-1082 and 1149-1153."
    },
    {
      "path": "tests/omp-extension.mjs",
      "description": "Best deterministic seam. `createHarness` captures one-shot SPT calls and listener spawns at 99-197. `testLifecycleCustodyAndContext` at 280-324 asserts bind → idle → bare ready. Deferred-bind tests at 415-488 guarantee listener startup ordering."
    },
    {
      "path": "adapter/omp-spt.toml",
      "description": "Declares immutable identities and all lifecycle roles. `[identity] post_spawn` is at 41-44; Psyche roles at 49-65; fresh/resume roles at 69-81; endpoint ID injection at 106-109. Manifest adapter name is `omp-spt` at 9-17."
    },
    {
      "path": "tools/omp-spt/src/launch_omp.rs",
      "description": "Separates normal fresh/resume OMP startup. `omp_argv` at 47-59 emits optional `-r <sid>` plus the packaged extension; `run` at 404-470 snapshots state then hands inherited stdio/PTY ownership to native OMP. It contains no SPT registration call."
    },
    {
      "path": "tools/omp-spt/src/psyche_omp.rs",
      "description": "Exonerates Psyche. Private session-dir and argv planning are at 78-122; argv explicitly includes `--no-extensions`; OMP spawn is at 271-284. No bind/ready/state/history registration is called."
    },
    {
      "path": "tools/omp-spt/src/echo_commune_omp.rs",
      "description": "Echo summarizer also uses `--no-extensions` at 202-216, so teardown summarization cannot recursively load the endpoint extension or produce startup registrations."
    },
    {
      "path": "tools/omp-spt/tests/launch_omp.rs",
      "description": "Existing integration seam for native fresh/resume argv only: fresh launch at 150-184 and resume launch at 325-354. It cannot observe extension/Spt registration because fake OMP only records argv and exits."
    },
    {
      "path": "docs/CI.md",
      "description": "Manual real-native acceptance at 44-50 exercises `spt endpoint run`, packaged extension bind, and reachability. It is the end-to-end confirmation layer, but currently lacks a public scriptable ledger assertion."
    },
    {
      "path": "docs/adr/0011-endpoint-session-binding-is-immutable.md",
      "description": "Architectural invariant: one endpoint owns one OMP session for its lifetime; switching is blocked and resume requires stop/relaunch. Duplicate synthetic startup history violates the intended single binding identity."
    }
  ],
  "architecture": "```text\nspt endpoint run --adapter omp-spt\n  └─ chooses [session.self] or [session.resume]\n      └─ omp-spt launch-omp\n          └─ native omp [-r prior-sid] --extension omp-spt.mjs\n              └─ public OMP session_start (initial load only)\n                  ├─ api --adapter omp-spt bind ENDPOINT --set-session-id REAL_OMP_UUID\n                  │    └─ correct boot/session-ledger identity\n                  ├─ api --adapter omp-spt state idle ENDPOINT --token TOKEN\n                  │    └─ activity only; not registration\n                  └─ bare spt ready ENDPOINT\n                       └─ public contract: register perch + listen\n                           [INFERENCE, strongly supported by observed shape]\n                           missing sid/adapter proof permits synthetic sess-PID-EPOCH boot row\n```\n\nPsyche is a separate bounded OMP process invoked through `[session.psyche_resume]`; it runs with `--no-extensions`, so it never enters this graph. Native resume changes only launch argv (`-r <sid>`), not the extension event or registration sequence. The public OMP `SessionStartEvent` has no source/boot/resume field, so the extension cannot legitimately derive `boot` from OMP; `boot` belongs to SPT’s lifecycle-trigger vocabulary, while `omp-spt` is the adapter identity.\n\nPublic references used: [OMP extension events](https://github.com/can1357/oh-my-pi/blob/main/docs/extensions.md), [OMP `SessionStartEvent` type](https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/extensibility/shared-events.ts), [SPT startup/bind/listen contract](https://sabermage.github.io/spt-releases/harness-contract/api.html), and [`spt ready` CLI reference](https://sabermage.github.io/spt-releases/cli/reference.html#spt-ready)."
}