4 Commits

Author SHA1 Message Date
xarmian 07215d4953 fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970) (#2249)
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)

Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.

wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.

Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)

Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)

Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)

Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)

Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* docs(plugin): the file header describes session start and wake (TASK-3970)

Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 00:26:16 -04:00
xarmian 5c598a18d3 feat(plugin): no event monitor at session start; an opted-in session's watch starts from the mod, and an unarmed one shows nothing (TASK-3513) (#1925)
* feat(plugin): no event monitor at session start; an opted-in session's watch starts from the mod, and an unarmed one shows nothing (TASK-3513)

Every session with the plugin opened with "Monitor ... ended without
producing output", and Claude explaining it: the "always" monitor ran
pad-monitor.sh, which exits when the session has not consented, and
Claude Code reports any monitor that exits. Monitors take only "always"
or "on-skill-invoke", with no condition and no silent exit.

The plugin is already a mod (the pane). Its session.start now:
- records presence (`pad session register`), silently, as the monitor did;
- asks `pad session should-arm`, PLAN-2613's gate, unchanged (.pad.toml
  push.auto_arm stays the per-project opt-in);
- only when armed, spawns pad-monitor.sh ($.process.spawn) and relays
  its stdout lines with $.prompt.submit, coalescing a burst into one
  message per idle window.
An unarmed session runs nothing and shows nothing: no process, no line,
no notice. session.end stops the stream, except /clear and /resume,
which keep the process, the socket consent is keyed on, and do not
refire session.start.

Measured (Claude Code 2.1.295): CLAUDE_CODE_MESSAGING_SOCKET reaches a
mod's processes, so consent and the wrapper's lockfile key match the
connect monitor's; CLAUDE_PID and CLAUDECODE do not, so the mod passes
PAD_SESSION_PID (its children's parent is the session process) and
CLAUDECODE=1.

- monitors.json: pad-events-auto removed; /pad:connect keeps its
  on-skill-invoke monitor, the explicit path and the path where mods do
  not load. The lockfile still keeps the two from streaming twice.
- pad-monitor.sh: runs the stream as a child the traps can name, so a
  caller that stops the script stops the stream too.
- SKILL.md and README: no "always-on wrapper"; the two delivery forms.

Tests (claude plugin test plugin, 18 pass, the pane's included): unarmed
registers and starts/shows nothing; no CLI; the CLI calls carry the
session pid; armed relays one message per burst (stderr dropped);
survives /clear and /resume; any other end relays nothing after it.
Controls: dropping the gate fails 2, ending on /clear fails 1, and the
relay's ownership check was found by its test (a late chunk relayed).

Hand-check in an interactive session (tmux, --plugin-dir): unarmed start
shows nothing and registers with the right pid; armed start spawns the
wrapper and stream under the session process; a submitted line renders as
"The pad plugin sent a message:"; the stream survives /clear; /exit takes
the wrapper, the stream and the lockfile with it.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(plugin): a session that ends while the CLI answers starts no watch (TASK-3513, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(plugin): a flush the session's end overtook relays nothing more (TASK-3513, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(plugin): pad-monitor.sh frees its lock only once the stream has closed (TASK-3513, codex r3)

Measured with a stand-in pad whose stream takes 1s to close on TERM: the
lock stays held through the close and is gone after, with no process left.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(plugin): a burst of watch lines goes to Claude after it goes quiet, capped at 10s (TASK-3513, codex r4)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 22:06:11 -04:00
xarmian e747a1610c feat(session): registry keyed on the harness session, carrying the agent name; pad session list / prune (TASK-2767) (#1200)
## Summary

TASK-2767 (IDEA-2750 part 2, with part 3 riding along — the keying fix and the reaping are one mechanism).

The local session registry (`~/.pad/sessions`) was keyed on the pid of the `pad session register` subprocess, which is dead before anyone reads the file. One session left a new file per call and its own pid appeared in none of them; the only live identifier was the harness pid a reader could parse out of the socket path's basename. In practice nothing wrote it (zero callers in `plugin/`, `skills/`, or hooks) and nothing read it.

Now:

- **One record per session, keyed on the harness session pid** — `$PAD_SESSION_PID` (harness-agnostic override), else `$CLAUDE_PID` (verified present in both the tool shell and a live plugin monitor's `/proc/<pid>/environ`), else the calling process. A set-but-invalid value is an error, not a silent fall-through.
- **The record carries the agent name** the session's writes are attributed to (`ResolveAgentName`: `.pad.toml agent_name` → `$PAD_AGENT` → detected runtime; `--agent` overrides, `--agent ""` is anonymous), the harness session id, and the messaging socket's identity (inode/device/mtime — the same binding the arm-state file uses).
- **One owner-identity type, one verdict.** `internal/cli/session_owner.go`: `SessionOwner` + tri-state `OwnerLiveness` (`alive` / `dead` / `unknown`). `armStateOwnerAlive` is now `OwnerLiveness(...) == alive` with its file contract preserved (socket identity else mtime; headless pid + start token; fail closed). The registry pruner takes the opposite posture on `unknown`: on Windows `pidAlive` reports dead for every pid, and a reaper built on that would delete every live session's record.
- **Verbs:** `pad session register [--agent]` (writes/refreshes; prunes dead records), `pad session list [--agent] [--cwd] [--all] [--format json]` (liveness per row, newest first; dead hidden unless `--all`), `pad session prune [--older-than DUR]` (dead always; unknown only under an explicit bound; alive never). Nothing on MCP — host-local filesystem state.
- **Who registers:** `plugin/scripts/pad-monitor.sh` runs `pad session register` on start, BEFORE the consent gate — presence is a fact, consent is a grant, and the record is local/0600/never on the wire.
- **Legacy v1 files** list as `legacy` rows: owner = socket-basename pid (else registrar pid), liveness by pid only (v1 recorded no socket identity, and the socket-without-identity rule would have judged every legacy record dead while its session ran). A legacy row can say a session exists, never who it is.

Lead rulings on the four open decisions, all as built: `agent`/`--agent` vocabulary; no server-presence merge in `list`; register from the monitor script before the gate; wire follow-on (agent name on the stream) filed separately as IDEA-2750 part 2b.

One ordering change from the plan's section A: pid precedence is `PAD_SESSION_PID` > `CLAUDE_PID` > self (explicit override beats detection, mirroring `PAD_AGENT` over runtime detection); the plan listed `CLAUDE_PID` first.

## Behaviour changes for existing users of `~/.pad/sessions` / `pad session register`

- Registry files are keyed on the **harness session pid** (`PAD_SESSION_PID` → `CLAUDE_PID` → self), not the `pad` command's pid; repeated registrations overwrite one record instead of accumulating.
- `pad session register` records the agent name, harness session id and socket identity; stores the **real path** of the cwd; prints a different text line and a different JSON shape (the full `SessionRecord`); and **rejects** an invalid `PAD_SESSION_PID` / `CLAUDE_PID` instead of silently keying on itself.
- Existing v1 files are read as `legacy` rows (owner = socket-basename pid, no agent name) and dead ones are pruned by the next register.
- The plugin monitor now registers (and prunes) on every start, before the consent gate.
- `armStateOwnerAlive` now delegates to the shared `OwnerLiveness`; the consent gate's observable behaviour is unchanged on every platform and key type (codex round 4 traced every caller; matrix M29 pins the socket-keyed mapping).

https://claude.ai/code/session_016zc6oxBvpax6Z3iQMsAJno
2026-08-25 15:31:16 -04:00
xarmian 052c971785 feat(plugin): consent-gated push monitor + tri-state arm/disarm + envelope (PLAN-2613 S3, TASK-2618) (#1150)
* feat(plugin): consent-gated push monitor + tri-state arm/disarm + envelope (PLAN-2613 S3, TASK-2618)

The plugin layer of the push-consent gate. S2 built the CLI arm/disarm/status
verbs and the arm-state file; S3 makes the monitor existence itself the gate
(D1) and adds the tri-state, the envelope, and the connect ritual.

- Tri-state arm-state file: a session can be explicitly ARMED, explicitly
  DISARMED, or absent. `pad session disarm` now writes a session-scoped OFF
  marker (not a file removal), so a within-session disconnect wins even in an
  auto_arm=true repo — the disconnect verb must not be a lie there. The marker
  dies with the session (same liveness), so across sessions auto_arm remains
  the standing contract. ResolveAnnouncedArmed folds the tri-state over
  auto_arm; the monitor announces its result.

- Gated monitors (monitors.json): the single always-on monitor is replaced by
  two — an `always` auto-arm monitor and an `on-skill-invoke:connect` manual
  monitor — both running scripts/pad-monitor.sh. The wrapper gates on a new
  hidden `pad session should-arm`, dedupes concurrent monitors with a
  liveness-aware per-session lockfile, and carries the reconnect loop so an
  in-session disarm stops the stream on its next reconnect. No consent → the
  monitor exits → nothing listening.

- D5 envelope: a push notification carries the verbatim direction-with-authority
  framing (confirm in-session before anything destructive/irreversible); item-
  change kinds stay a light informational label.

- /pad:connect + /pad:disconnect skills; /pad:status gains a one-line connection
  header from `pad session status`. /pad:connect runs the workspace's
  on-session-start playbooks on the first connect only (D8), tracked by a
  Booted flag carried forward across arm/disarm. plugin 0.2.1 → 0.3.0.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(plugin): address Codex R1 on S3 (disarm stops active stream, fail-closed local state)

- HIGH-1: a within-session disarm now stops an ACTIVE stream, not just the
  next reconnect. The monitor re-checks consent every 2s while streaming and
  cancels the connection when it flips to not-armed, then exits (D1's whole-
  stream-behind-consent gate at the top of the loop), so the plugin wrapper
  keeps it dead. Fixes /pad:disconnect being a lie for an idle SSE that might
  never naturally reconnect.
- HIGH-2: a corrupt/unreadable local arm-state file now fails CLOSED
  (LocalArmError -> not armed) instead of falling through to auto_arm, so a
  corrupted disarm marker can't silently re-arm an auto_arm repo. It is not
  reaped (reaping would re-arm on the next read); it is session-keyed and a
  re-arm overwrites it.
- Shell wrapper: an empty (mid-startup) lock pid is treated as live so two
  monitors can't both steal the lock; INT/TERM now exit (a trap otherwise
  resumes the loop and reconnects without a lock).
- Docs: plugin/skills/pad describes the new push-envelope line format;
  connect/status skills distinguish "consent set (armed)" from the server's
  observed connection counts rather than claiming "Connected".

Bounded/safe-direction residuals documented in code: the reap TOCTOU and the
Booted carry-forward race (both fail-closed / benign), and lock pid-reuse
(dedupe only, fails toward not-streaming).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(plugin): address Codex R2 on S3 (disarm-watcher timing, semantic corruption fail-closed)

- HIGH-1: the disarm-watcher now starts BEFORE the connection is opened, so a
  disarm during connection/header negotiation cancels the request too (the
  request is built on streamCtx). streamWatchEvents also re-checks consent
  before delivering each notification and stops the stream if it was
  withdrawn, so no push is printed after a disarm even within the poll window.
- HIGH-2: a syntactically-valid but semantically-garbage arm-state file (e.g.
  {} or {"pid":1}) now fails CLOSED via a well-formedness check (StartedAt +
  PID must be present, as our writer always stamps them) before liveness or
  reaping — so it can't be judged owner-dead, reaped, and re-armed through
  auto_arm, nor mistaken for a live headless arm naming init.
- LOW: the cleanup trap uses condition 0 (portable) rather than the EXIT name.
  The disconnect skill note reflects the ~2s active-stream drop.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(plugin): /pad:disconnect always disarms, never gated on a linked workspace (Codex R3)

Consent is session-scoped (keyed by the messaging socket, not the workspace),
so a session that connected in one repo must be able to disconnect from
anywhere — including a directory with no .pad.toml. The old precondition let a
session move to an unlinked directory, "disconnect", and keep receiving pushes.
Verified: `pad session disarm` from an unlinked cwd disarms the socket-keyed
session state; should-arm then reports not-armed back in the original repo.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(cli): enforce the Armed != Disarmed writer invariant in arm-state validation (Codex R4)

armStateWellFormed checked only StartedAt + PID, so a well-stamped file that
violated the writer invariant — both armed and disarmed false (or both true) —
passed validation and, since SessionArmState only branches on Disarmed,
resolved to LocalArmOn and armed. The writer always sets exactly one of the
two; require it, so a neither/both file fails closed (LocalArmError).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 00:24:19 -04:00