Commit Graph

180 Commits

Author SHA1 Message Date
xarmian 786406e569 fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999) (#2346)
* fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999)

Each CLI invocation sends a new idempotency key, so re-running a command
after an unclear outcome is a new request. Failed writes that sent an
op_key (create, comment, update/note/decide, patch, undo, push, copy) now
wrap the error in cli.OpKeyError, and the root error writer prints the key
on stderr with the --op-key re-run hint. Chat is excluded (caller-owned
key). SKILL.md no longer says a re-run never applies a patch twice.

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

* fix(cli): read APIError through the op-key wrapper at every call site (BUG-3999)

A write that sent an op_key comes back wrapped in OpKeyError, which a direct
err.(*cli.APIError) assertion does not see through: pad item create lost its
plan-limit structured marker. Add cli.AsAPIError (errors.As) and use it at all
17 command-side sites. Add the plan-limit, --format json, decide and push tests.

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

* fix(cli): keep the op-key hint when create replaces a plan-limit refusal (BUG-3999)

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

* fix(skill): drop the false retry claim from the plugin's SKILL.md copy too (BUG-3999)

plugin/skills/pad/SKILL.md is maintained separately from the embedded
skills/pad/SKILL.md (it is not synced from it), and it still said a plain
re-run "never applies it twice". It now carries the same corrected text,
and the prose pin checks both files. Adds a test that a failed write which
sent no op_key prints no hint (r1 H1, H2).

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-29 12:39:35 -04:00
xarmian a8af168304 fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) (#2328)
* fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090)

The session registry's verdict probed the owner pid with kill(pid, 0), and
a pid means something only inside one PID namespace. From a sandbox or
container shell (bwrap --unshare-pid) every host pid is ESRCH, so
`pad session list` read every live host session dead, and `pad session
prune` - and the prune that follows every `pad session register` -
deleted their records.

Records now carry pid_ns, the namespace the owner pid was captured in,
stamped only when that pid is signalable there. The reader compares:
across a boundary the pid is not probed and a missing socket is not
evidence, so the verdict is unknown (kept by prune, untrusted by the arm
gate). Only a socket answering with a different identity stays dead. A
row without pid_ns read from a non-init namespace keeps a positive pid
probe and turns a negative one into unknown. Headless arm-state files
carry pid_ns too.

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

* fix(cli): judge a session's socket by its mount namespace (BUG-4090 r1)

The socket half of the liveness verdict now keys on the mount namespace
the socket was seen in (mnt_ns, stamped only beside a socket stat'ed with
its identity). A reader with another filesystem view gets unknown for a
missing or different node; a row without mnt_ns keeps the old rule, so a
container reading its own rows (or the Nix sandbox) behaves as before.
The unverifiable prose now says "from here (this platform, or another
namespace)", and the bwrap test gains a leg for a pre-fix, unstamped row.

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

* fix(cli): a foreign-namespace reader never manufactures dead; chat direction stamps mnt_ns (BUG-4090 r2)

An unstamped row read from a non-init PID namespace now reads alive only
on a start-token match: a mismatch, a vanished pid or a zombie at that
number describes the reader's namespace, not the owner's, and is unknown.
The chat-direction file, the third socket-identity writer, records its
socket's mount namespace like the registry and the arm file. The help and
CLAUDE.md no longer overclaim for rows written before this version.

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

* fix(cli): an unidentifiable namespace is a possible boundary on Linux (BUG-4090 r3)

A reader that cannot read /proc/self/ns/{pid,mnt} (a sandbox with no
procfs) no longer treats an unstamped row as same-namespace: its ESRCH or
missing socket is unknown, not dead. A Linux writer in that position
stamps pid_ns/mnt_ns with the sentinel 1, which no reader's namespace
matches, so every reader treats the row as crossed. Off Linux, where
namespaces do not exist, nothing changes.

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

* fix(cli): an empty start token is not a match for a foreign-namespace reader (BUG-4090 r4)

An unstamped row with no proc_start, read from a non-init or unreadable
PID namespace, read alive whenever any process answered at its number
there. That can honour a headless arm or disarm on an unrelated process.
With nothing to match, such a reader now says unknown; the bare-probe
residual stays for same-namespace and init readers.

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

* test: namespace-correct session fixtures for runs outside the init pid namespace (BUG-4090)

The Nix check runs go test in its build sandbox's PID namespace, where an
unstamped registry row is judged by BUG-4090's foreign-reader rule. Three
cmd/pad tests hand-wrote such rows and expected init-namespace verdicts.
The two dead-pid fixtures are now stamped with the test's own pid_ns, so
they mean "same namespace, dead pid" anywhere; the v1 legacy-row table
assertion follows the test's namespace. internal/cli gains a seam test
that drives the legacy row's verdict through init, non-init and
unreadable readers on every runner.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC
2026-10-26 15:34:50 -04:00
xarmian ffecfc744b feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)
* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071)

pad watch --stream --for-session appends every chat line it prints to
~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW),
capped at 256 KiB with one .1 generation, so the human at the agent's
terminal can see it. No log when the pid is pad watch's own; pruning a
session record removes its log.

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

* test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071)

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

* test(chat): refuse a FIFO that has a reader (TASK-4071 M13)

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

* feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071)

The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's
first use of Pad, starting at the end of the file, and shows new lines in
a band above the prompt (the last three, until the person's next prompt)
and one toast per burst. Lines rotated away between polls are not shown.

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

* refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5)

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

* fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071)

The monitor prints nothing before it has something to deliver (DOC-2479),
so the no-harness-pid line waits for the first chat line. The pad watch
--stream --for-session and pad session prune help now describe the log.

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

* fix(chat): r1 findings on the chat-seen log and reader (TASK-4071)

- Refuse a symlinked chat-seen directory instead of chmodding its target.
- Reopen a log that was removed under the writer.
- Remove .log.1 before rotating: Rename does not replace on Windows.
- Reader: drop control characters and direction overrides before they
  reach the terminal; clip by code points; one toast per burst on the
  relay's clock; a stop during start leaves nothing running; a failed
  first read no longer replays the log.

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

* fix(plugin): r2 findings on the chat-seen reader (TASK-4071)

- The first look skips what the log held at its stat, so a line written
  between that stat and the read still shows.
- A stop during a poll no longer leaves the reader stuck after the next wake.
- An unreadable moment (failed stat, a symlink) changes nothing, so it
  cannot cause a replay.
- U+061C joins the stripped bidi controls.

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

* fix(plugin): r3 findings on the chat-seen reader (TASK-4071)

- Start a log at zero only when the listing shows it absent; a first look
  that merely failed leaves the start to the first good read (R3-1).
- Restore the byte-size rotation reset beside the character one: a log of
  multi-byte text can shrink in one while it grows in the other (R3-2).

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 07:17:51 -04:00
xarmian cba880ca17 fix(config): warn on a relative PAD_DATA_DIR / PAD_DB_PATH; anchor install-refresh's marker and config reads (BUG-4066 phase 1) (#2308)
* fix(config): warn once per process on a relative PAD_DATA_DIR / PAD_DB_PATH; install-refresh anchors its marker and config reads (BUG-4066 phase 1)

config.Load takes PAD_DATA_DIR verbatim, or PAD_DB_PATH's dirname, with no
filepath.Abs, so every process resolves a relative value against its own cwd:
a CLI and a server started in different directories silently use different
data (database, attachments, key). Phase 1 (lead ruling 4b4cb4ff) leaves the
resolution unchanged and makes it loud:

- One helper, envDataDir(), is the only reader of the two variables (Load and
  userConfigPath both use it). A relative value warns ONCE per process,
  naming the absolute path this process resolved, that a CLI and a server in
  different directories will use different data, and that a future release
  refuses it (TASK-4072). A literal '~' gets a hint. A CLI prints it to
  stderr; `pad server start` logs it through slog; `pad watch --stream
  --for-session` (the plugin monitor) suppresses it.
- install-refresh anchors the refresh marker and config_value the way 2a
  anchors the log (${SERVER_CWD:-$PWD}), so the marker is written and
  cleaned up in one place (it used to leak under the caller's cwd), and warns
  before anything is read or stopped.
- docs/deployment.md: both variables must be absolute; relative is
  deprecated and warns.

Tests: the warning rule (and Windows, GOOS-guarded), the marker and the
warning-before-stop end to end, config_value at function level, and
child-process tests for the monitor's silence and the server's slog line.

Harness guard (after this unit's draft test stopped the box's shared :7777
server twice): every install-refresh exec in internal/buildtools now passes
requireSafeScriptEnv first, which refuses an environment whose resolved port
is missing (the script would fall back to 7777) or is 7777, whose HOME or
data dir is the real user's or outside a test temp dir, or whose port answers
on a LAN address without this test's random stub token.

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

* fix(config,mcp,scripts): the relative data-dir warning names the server's path and stays out of agent-facing errors; harden the test guard (BUG-4066 r1)

r1 rulings (c0ef54e8):
- install-refresh's warning now runs once SERVER_CWD is captured (still
  before the marker and the stop) and names the anchored path
  (${SERVER_CWD:-$PWD}/<dir>, a leading ./ stripped); when the caller's cwd
  differs it also names the caller's reading. It used to name $PWD/<dir>,
  which the server does not use.
- `pad mcp serve` checks the data dir once on its own stderr; the stdio
  dispatcher's `pad` children get PAD_INTERNAL_NO_DATA_DIR_WARNING=1, so the
  warning is not folded into a failed tool call's agent-facing error. The
  variable is internal (code-documented only), so users cannot silence the
  deprecation. Shell completion no longer prints the warning mid-Tab.
- Test guard: refuses a relative data dir with a config-sourced port (the
  script reads that port from the server's cwd, which the guard cannot see);
  compares symlink-resolved temp paths (macOS); asks for the stub token on
  the LAN address that answered; compares 7777 numerically; and a source-scan
  test fails if any install-refresh exec in the package is unguarded.

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

* fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

- PAD_DB_PATH warning names the resolved data dir (Dir of the value), in Go and in install-refresh.sh; the script normalises . and .. in pure shell.
- Shell completion (__complete, __completeNoDesc) sets the silent sink in main.
- MCP resource and bootstrap fetchers exec with dispatchChildEnv; a population scan pins every pad exec in internal/mcp.
- Harness guard: numeric 7777 refusal before any dial (asserted by message), PAD_HOST must be empty or loopback, temp-dir check scoped to this test, hardened and self-tested source scan, marker-presence probe in the restarted stub, child timeout, sink reset.

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

* fixup! fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

test(buildtools): the config-file 7777 row asserts the numeric refusal by message too (BUG-4066 r2 item 12, same class)

* test(buildtools,cmd): close the m15, m24 and m28 survivors (BUG-4066 r2)

m24: a scanner self-test case whose variable never runs in its function,
with a guard in a later function, so the function boundary is what flags it.
m28: insideTestTempDir refuses another test's directory, and admits a
subtest's, which t.TempDir names <Parent><sub...> with no separator.
m15: the --workspace completion func no longer sets its own sink. main()
silences every completion request, which made that line dead code.

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

* fix(cli,buildtools): an auto-started server does not inherit the warning silencer; the guard reads the config port like the script (BUG-4066 r3)

Item 2: an MCP tool-call child carries PAD_INTERNAL_NO_DATA_DIR_WARNING=1
and is often the process that auto-starts the server, so that long-lived
server never logged the relative data-dir warning. serverStartCommand drops
that one variable from the spawn env, and keeps every other one. It is the
only `server start` spawn in internal/cli; a source test pins both facts.

Item 7: the harness guard took the leading digits of config.toml's port, so
"8080abc" read as 8080 where install-refresh rejects it and falls back to
7777. It now reads the first port line the way config_value does, and a
non-numeric value is refused as unreadable.

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

* fix(scripts): install-refresh's relative-dir warning is one line; the completion tests run main() (BUG-4066 r4)

Item 1: when the server's cwd differs from the caller's, the warning printed
a second "Warning:" line naming the shell's-directory reading, and a third
for a leading ~. Phase 1's contract is one warning per process, as
config.Load prints it, so both now ride in the one line. The test asserts
both paths sit in that line and that it prints once.

Item 6: the completion child cases called silenceWarningsForCompletion
themselves, so deleting main()'s call passed every test. They now set
os.Args and run main().

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 01:13:51 -04:00
xarmian bc2a32895e feat(chat): monitor reply quotes and a session-start DM ensure (TASK-4043) (#2309)
* feat(chat): render monitor reply references and excerpts (TASK-4043)

* feat(chat): ensure owner-agent DM once per monitor process (TASK-4043)

* docs(chat): describe monitor quotes and DM startup (TASK-4043)

* test(chat): accept startup DM ensure in recovery fixture (TASK-4043)

* fix(chat): wait a default when a 429 has no Retry-After (TASK-4043 r1)

* fix(chat): bound Retry-After before it can overflow the wait (TASK-4043 r2)

A Retry-After too large for a time.Duration (>= 9223372037s) wrapped
negative and made the ensure retry hot. Seconds are now bounded before
the multiply; zero, negative, absent and overflowing values all wait
the 120s default, and the largest value that fits is honoured.

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

* test(chat): keep the Retry-After table portable and hermetic (TASK-4043 r3)

The overflow rows are int64 and skip where int cannot hold them; the
rows run with a temp HOME.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-24 22:55:32 -04:00
xarmian d3f87d205a feat(chat): delivery, framing and the accept-instructions opt-in (TASK-4041) (#2305)
* WIP TASK-4041: add ref-only chat delivery and explicit acknowledgements

* WIP TASK-4041: implement local consent and fetched monitor delivery

* WIP TASK-4041: document chat delivery and session instruction consent

* WIP TASK-4041: rename chat arm files (_arm.go is a GOARCH build constraint)

* test(chat): fix delivery fixture and strengthen consent guards

* test(chat): pin replay publication guard after mutation audit

* test(chat): make review guards detect their named regressions

* docs(chat): clarify bounded excerpts and defer unwired command guidance

* test(chat): cover empty agent pairs and the Redis envelope

* fix(chat): seed gap fills and acknowledge contiguous output

* test(chat): restore gap regressions for amended ack rule

* fix(chat): advance contiguous delivery across own rows and tombstones

* test(chat): cover final tombstones, ack retries, and sync recovery

* fix(chat): acknowledge final contiguous tombstone events

* fix(chat): retry contiguous delivery ack on duplicate events

* fix(chat): reseed delivery baselines on sync required

* fix(chat): wire session consent commands and reuse chat client

Pin arm/disarm execution through the real root tree and restore the deferred skill and monitor guidance.

Wiring adds both local consent commands to the cmdhelp Document supplied to MCP. Preserve TestChatArmHasNoMCPPath's failure for the requested ruling; do not change MCP goldens or versions.

* test(mcp): pin chat direction dispatch across transports

* docs(cli): add chat direction command examples

* test(chat): expose delivery memory loss and unsafe acknowledgements

* fix(chat): retain process delivery memory independently of fill cursors

* docs(chat): describe local delivery memory and failed-summary recovery

* test(chat): pin both monitor adapter fetch limits

* docs(api): state chat owner-message framing in the present tense (TASK-4041)

The TASK-4039 provenance caveat described TASK-4041 behaviour as future
work. It is implemented now: say what is true and point at the framing
rules under GET /api/v1/events/stream.

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

* test(chat): expose failed resync, late retraction and identity collisions

* test(chat): keep push delivery when monitor identity is unavailable

* fix(chat): preserve closed recovery and retraction ownership per identity

* docs(chat): clarify recovery, page cap and identity memory limits

* test(chat): recover after a transient monitor identity lookup failure

* fix(chat): retry transient identity failures on chat references

* test(chat): require deferral until a safe server summary

* fix(chat): defer events until a server delivery floor is available

* test(chat): pin per-conversation server floors and caller boundaries

* feat(chat): expose caller delivery floor on conversation GET

* fix(chat): fetch per-conversation floors before monitor delivery

* test(chat): require bounded synchronous first-contact delivery and backoff

* fix(chat): fetch first-contact floors synchronously with bounded backoff

* test(chat): remove redundant break from synchronous stream fixture

* test(chat): cover stream deadlines, retry windows and cookie agent isolation

* fix(chat): bound stream HTTP calls and back off failed conversations

* docs(chat): clarify direction consent and stream delivery limits

* test(chat): preserve independent dedupe eviction and retry clocks

* fix(chat): require bearer authentication for stream agent addressing

* test(chat): cover r10 tombstone, summary and arm-generation regressions

* fix(chat): fill tombstone gaps and back off unread summaries

* fix(chat): bind direction consent to a random arm generation

* test: pin r11 retractions and session direction cleanup

* fix: retract owed chat deletions before network backoff

* fix: report session action when direction removal fails

* docs: clarify chat direction rearming and connect state

* test(chat): bound HTTP deadline failures and cover unresolved retractions

* docs(chat): clarify immediate retractions and session re-arm direction loss
2026-10-24 15:16:30 -04:00
xarmian 6e37c69934 test(cli): arm-state tests own the session pid env (BUG-4025) (#2298)
* test(cli): arm-state tests own the session pid env (BUG-4025, BUG-4057)

armStateTestEnv reset HOME and the messaging socket but not PAD_SESSION_PID
or CLAUDE_PID, which CaptureSessionOwner prefers over this process. A gate
inherits them from the harness that started it, and when that session is
relaunched mid-run the dead pid made TestArmState_HeadlessLivePid read
disarmed. The helper now clears both. TestArmStateTestEnv_IgnoresAmbientSessionPID
plants a dead pid first, so the guard holds in CI too, where CLAUDE_PID is
never set.

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

* test(cli): put HeadlessLivePid's doc comment back on it (BUG-4025, r1)

Lead ruling on r1: the new test had been inserted between
TestArmState_HeadlessLivePid and its doc comment. Comment-only.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 04:22:21 -04:00
xarmian 51b0bb4de9 feat(cli,mcp): pad chat CLI and pad_chat MCP tool (TASK-4042) (#2295)
* feat(cli): add global pad chat commands (TASK-4042)

Add conversation targeting, inbox and message commands with caller-owned idempotency and precise read markers. Preserve API responses for JSON output and use cwd workspace links for reference context.

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

* fix(cli): add chat examples and isolate command tests (TASK-4042)

Satisfy the CLI help-tree example contract and restore package-level flags after command regression tests.

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

* feat(mcp): expose global pad_chat on both transports (TASK-4042)

Add six chat actions with shared target parsing, uniform authenticated dispatch, precise read markers and structured unnamed-agent errors. Exclude agent chat from ChatGPT and pin the direction guard. Bump the surface to 0.102, update documentation and goldens, and shorten duplicated prompt prose within the fixed budgets.

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

* test(mcp): label global chat scope judgments (TASK-4042)

Mark the membership-based global-chat exemption reasons explicitly, as required by the allowlist coverage guard.

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

* fix(chat): preserve unread gaps and successful outcomes (TASK-4042, seat r0)

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

* fix(chat): restore agent rules within prompt budgets (TASK-4042, seat r0)

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

* fix(chat): keep scope fixture contiguous with read marker (TASK-4042, seat r0)

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

* fix(chat): guard read boundaries and direction inputs (TASK-4042, r1)

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

* fix(chat): validate MCP bodies and report mark outcomes (TASK-4042, r1)

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-23 22:08:29 -04:00
xarmian f8756a0b5b feat(server,cli,plugin): agent presence — agent name, worker id and busy/idle activity (TASK-4040) (#2293)
* test: pin agent presence and worker-scoped session activity

* feat: carry agent presence and track activity by user and worker

* docs: describe worker presence and session activity states

* fix: include hook activity examples in CLI help

* fix(cli): keep session activity hooks silent on failures

* fix(server): tolerate activity read failures in session lists

* docs: place activity keys after presence registry details

* fix(plugin): wire silent Claude Code activity hooks

* docs: explain session busy-state hook limits

* fix: bound the entire session activity command

* fix: batch session activity reads across workers

* docs: describe detached activity reports and timestamp ordering

* fix: order worker activity atomically by client timestamp

* fix: detach hook activity reports with captured timestamps

* test: reproduce detached activity losing registered identity

* fix: capture activity identity before detaching the reporter

* test: isolate activity registry home on Windows

* docs: clarify activity ordering and hook failure behavior

* fix: bound activity reordering to ten seconds

* fix: replace activity records with corrupt client timestamps

* test: prove activity parent exits while child response is held

* fix(session): derive activity reorder window from report bound

* fix: replace non-string Redis session activity records

* test: cover cleared and expired session activity ordering

* test: cover older timestamp after legacy activity

* test: cover corrupt activity nanos and malformed JSON

* test: verify activity child deadline and process isolation

* docs: clarify read-only tokens cannot report hook activity

* test: strengthen session activity liveness and timing guarantees

* test: pin malformed null session activity timestamps

* test: harden session activity detach regression checks

* test: skip activity process-group check when ps is unavailable

* test: tie activity parent-exit bounds to the HTTP timeout

* test: bound activity liveness checks by the child HTTP deadline

* test: require ps for process-group checks in Go CI
2026-10-23 19:46:02 -04:00
xarmian fdbba1a31e feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035) (#2290)
* feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035)

Read-only wrappers over GET /workspaces/{ws}/catch-up on the CLI, local and
remote MCP, and the ChatGPT surface (catch_up). Mark caught up (PUT /me/seen)
stays web-only (ruling 380aa793): no verb, flag or client method moves the
cursor. ToolSurfaceVersion 0.100 -> 0.101, additive. Existing tool
descriptions were reworded to fewer bytes (no rule, caveat, refusal or ref
dropped) so tools/list keeps 901 B of headroom under an unchanged ceiling.

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

* test(cli,mcp): ChatGPT catch_up in the wire golden and the leak sweep (TASK-4035)

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

* fix(cli,mcp): catch-up json skips the typed decode; the re-indent claim is corrected (TASK-4035)

--format json prints the server's JSON re-indented without the typed
decode; table and markdown keep it. The MCP description names reopened.

Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
2026-10-23 00:05:52 -04:00
xarmian aad3c35bdb fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024) (#2279)
* fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024)

`pad item list --format json` stopped at the CLI's own 200 default and said
so on stderr only, so a consumer parsing the array saw 200 of 204 rows with
no signal in the payload. Ruling: with --format json and no --limit (cobra
Changed, not a value compare) the CLI follows X-Pad-Next-Offset to the end,
sends no limit and prints no notice. The payload stays a plain array; no
API, schema or MCP change.

An explicit --limit, including --limit 0, keeps today's behaviour and its
stderr notice. --limit 0 has always meant "the default" (clamped to 200),
not "no limit"; a test pins that. Table and markdown are untouched.
--offset with no --limit pages from that offset to the end.

One private pager (listAllPages) now backs ListAllItems and
ListAllCollectionItems (the old names stay as wrappers) and new *Report
variants taking a start offset and returning the first page's undeclared
filter keys, so the BUG-3480 warning still fires. A non-advancing next
offset, or more than 1000 pages, is an error and a non-zero exit with a
message naming the cap, never a partial array. This also applies to the
existing callers (reconcile, github status, project standup), which used to
return a short list for the non-advancing case.

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

* fix(cli): the list pager requires has-more pages to be full and contiguous (BUG-4024 R2-F1)

listAllPages accepted any next offset greater than the current one, so an
empty has-more page, or a next offset that skipped rows never returned,
followed by a has-more=false page, ended the loop on a partial list with no
error. The server sets has-more only when it trimmed the probe row
(handlers_items.go listItemsPage), so such a page holds exactly `limit` rows
and next-offset == offset+len(items) with len(items) > 0. The pager now
requires exactly that and otherwise returns an error with nil items. The
check subsumes the old `NextOffset <= offset` test.

Tests: empty page (advancing and non-advancing next offset), a jump, and one
short, at the client level; the jump at the `pad item list --format json`
level (non-zero exit, empty stdout).

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

* fix(cli): the list pager reads a negative start offset as 0, as the server does (BUG-4024 R3-F1)

Claude-Session: https://claude.ai/code/session_01UiMM6MkrYr1nkuaNkSmkeD
2026-10-21 22:34:38 -04:00
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 ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864)

A 403 from one of the shared gates now carries details.reason (role,
token_scope, no_access, workspace_not_authorized), with code and message
unchanged. MCP builds one permission_denied envelope from it on both
transports, with a hint that says what to do next. It used to lead with
"Insufficient role" for every 403, so a bot with a read-only API token was
told to ask for a role. An unverified Cloud email is reported as the
server's own email_not_verified code.

Stdio gets the same envelope from a structured marker the CLI root writes
for a 403 coded forbidden, permission_denied or email_not_verified. Before
it, the token-scope and unverified-email refusals said server_error there.

ToolSurfaceVersion 0.99 -> 0.100 (behavior).

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

* fix(server): the inline workspace-role gates name their reason too (BUG-3864)

The bulk endpoint, the owner-only member doors and the owner-only app
install and upgrade doors say role, or no_access for a guest.

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

* fix(mcp): the permission hint is the same on both transports (BUG-3864)

The remote hint appended the route, which stdio has no way to know, so one
refusal reached the two transports with different hints. The tests compared
prefixes only and missed it; both now assert the transports agree exactly.

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

* fix(server): no role hint for a caller with no role to change (BUG-3864)

requireMinRole and the grants list said role for a guest, who holds grants
rather than a role, and for a caller with no workspace role at all. Both
now go through roleGateReason, which answers no_access for either.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-17 19:11:58 -04:00
xarmian a630f867f4 fix(cli): a credentials save stamps its own file's mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838) (#2203)
* fix(cli): a credentials save sets the mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838)

BUG-3829's emptyConfirmed memo assumed a save's truncate moves an already
empty file's mtime. That was measured on ext4 only; POSIX moves mtime on
ftruncate only when the size changes, and macOS and Windows were never
measured. On such a platform a process that had memoized an empty file could
answer empty during another process's unlocked save and lose an entry.

writeFileInPlace0600 now sets the mtime itself (os.Chtimes, best effort like
the chmod) before the truncate, so the memo no longer depends on the
truncate's side effect. loadStoreLocked also reads once more when the stat
after its last empty read shows a body (hunter r6 on BUG-3829).

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

* fix(cli): on Windows the save touches its own handle, not the path; the stalled-save test releases on failure (BUG-3838)

Codex r1 could not establish that os.Chtimes works on an open file on
Windows. It does not, safely: UtimesNano opens the path again with only
FILE_SHARE_WRITE, so it fails against a reader holding the file and, while
open, makes a concurrent reader's open fail. touchCredentialsFile now uses
SetFileTime on the handle the save already holds (GENERIC_WRITE includes
FILE_WRITE_ATTRIBUTES); other platforms keep os.Chtimes by path.

The test's stalled save is released and waited for in cleanup, so a failure
before the release leaves no goroutine in the seam (codex r1 Low).

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

* test(cli): the stalled-save test's wait for the write is bounded (BUG-3838)

A save that failed after its release left the read seam spinning until the
global test timeout (hunter r2). It now gives up after five seconds and fails;
cleanup reports the save's error.

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

* fix(cli): on Unix the save's touch goes through its descriptor; the stalled-save test bounds its wait for the truncate (BUG-3838)

Codex r3 Medium: os.Chtimes by path stamps whatever the path names when it
runs, so a path replaced or a symlink retargeted after the open could take
the stamp while the file being truncated kept its old mtime. On Linux, the
BSDs and Darwin the touch is now unix.Futimes on the open descriptor (Linux
resolves it through /proc/self/fd), falling back to the path only when that
fails. TestBUG3838_TouchStampsTheOpenedFile retargets the symlink after the
open and checks which file moved.

Codex r3 Low: the test waited on the seam's truncate signal without a bound;
it now fails if the save returns first or never stalls.

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

* fix(cli): the Unix touch has no path fallback (BUG-3838)

Codex r4 Medium (hunter r4 L2): when the descriptor touch failed, the
os.Chtimes(path) fallback re-resolved the path and could stamp a file other
than the one the save opened. A failed descriptor touch now leaves the save
relying on its truncate, which moves an empty file's mtime on Linux, the one
shipped Unix where futimes can fail for want of /proc.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 17:49:09 -04:00
xarmian 1d08594dc0 fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829) (#2195)
* fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829)

A writer that waits out credentialsLockWait saves without the lock,
truncating in place before it writes. A loader holding the lock read that
truncate as an empty store on its first read, and an UpdateStore landing
there saved its one change over every other server's entry.

loadStoreLocked now re-reads an empty file with the bound the unlocked load
already uses (10 x 20ms). One that stays empty is genuinely empty (a crash
between a save's truncate and its write) and loads as an empty store, so
login still works over it; the unlocked path keeps refusing. A genuinely
empty file pays the bound once per process: the confirmed file state
(same file, same mtime) is remembered, and a save that touches the file
moves its mtime and is re-read again.

Residual, in the code comment too: an unlocked writer that stalls longer
than the bound between its truncate and its write is still read as empty.

TestBUG3828_LockedEmptyFileIsEmpty's premise ("under the lock nothing can
be mid-save") is what this bug falsifies; it now expects the re-reads.

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

* fix(cli): the confirmed-empty memo only remembers a file emptied 2s or more before (BUG-3829)

Codex r1 (Medium): on a filesystem with coarse timestamps, a save landing in
the same tick as the confirmation could leave the mtime unchanged, so the
memo would match the save's truncate and skip its re-read. A later save is
stamped no earlier than the confirmation, so remembering only a file whose
mtime is two seconds older than that rules the collision out. A file
emptied more recently pays the re-read bound on each load until it ages.

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

* test(cli): a credentials file with a future mtime is never remembered as empty (BUG-3829)

Codex r2 (Medium): a future mtime reads as recent to the age check, so such
a file pays the re-read bound on every load. That is the safe direction,
re-reading rather than trusting it, so the policy is now stated in the
comment and pinned by a test instead of changed.

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

* fix(cli): the confirmed-empty memo records only an empty file state (BUG-3829)

Codex r4 (Medium): the stat that records a confirmed-empty file follows the
empty read, so a save completing in between could be recorded, and a later
truncate keeping its mtime could match it. set now requires size 0.

The memo comment now states the measured fact the hunter questioned: a
truncate moves the mtime even of an already-empty file (ext4, Linux 6.17).

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 07:15:49 -04:00
xarmian e17f17f513 fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828) (#2187)
* fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828)

Save truncates credentials.json in place before it writes, under the
exclusive lock. A caller that could not take the lock (a holder past
credentialsLockWait, a filesystem without flock) read an empty file as an
empty store, and UpdateStore then saved its one change over it, wiping
every other server's credentials once the other save finished.

withCredentialsLock now tells its callback whether it holds the lock.
Without it, an empty file is re-read up to 10 times, 20ms apart. One that
stays empty is errCredentialsEmptyUnlocked: LoadStore answers no
credentials, as before (a read is only a read), and UpdateStore refuses
and saves nothing. Under the lock, empty still means empty.

TestBUG3649_ReaderNeverSeesAPartialFile paces its writer and checks that
every read took the shared lock, so it measures the locked path it names.

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

* test(cli): the BUG-3828 pins count re-reads through a read hook instead of timing them

Codex r1: the re-read test could pass without a re-read when its goroutine
was delayed past the fixed 60ms write, and the locked-path test used a 2s
wall-clock bound that a contended runner could exceed. readCredentialsFile
is now a variable; the tests count reads and fill the file on a given read.

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

* test(cli): the BUG-3828 pins overlap a real truncate-then-write save

Codex final round: the re-read test filled an empty file rather than
overlapping a save, and a second hook layered on the first counted every
read twice. midSave now truncates a full file at one read and writes it
back at another; the overlap is tested for an update that recovers, an
update that refuses (and leaves the save in progress untouched), and a
read. Each test installs one hook.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 00:25:35 -04:00
xarmian 22dcce6e6c fix(cli): a failed stat fails the credentials write instead of skipping the truncate (BUG-3810) (#2181)
writeFileInPlace0600 read a failed f.Stat() as "not a regular file" and
skipped the truncate, so a shorter body left the old tail behind and
credentials.json was corrupt. It now returns the stat error before writing
anything. statOpenFile is the test seam.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 18:57:28 -04:00
xarmian a99a040cf3 fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]

Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.

The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.

Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.

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

* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]

Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.

Test: an existing 0666 lock file reads 0600 after UpdateStore.

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

* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)

Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.

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

* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)

Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.

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

* fix(cli): tightening the credentials lock file is best effort (BUG-3649)

Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)

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

* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)

Both pilot hunters on d164f57aa (Haiku Medium, Sonnet Low): a lock file
a sudo run creates is root-owned 0600, so the open fails with EACCES
before the chmod. The comment named that as the case it fixes. It now
names the case it does cover (another uid's file this user can write)
and says the sudo case fails at the open, as a foreign-owned
credentials.json always did. Comment only.

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

* fix(cli): credentials Save writes through a symlink and retries a shared rename on Windows; a wipe always locks (BUG-3648, BUG-3649)

Final whole-diff review round (Codex, plus both pilot hunters):
- Codex (Medium): WipeCredentialsFile skipped the lock when ~/.pad did not
  exist, so an update creating it could land after the wipe. It now
  creates the directory and always locks, as UpdateStore does.
- Haiku (Medium) and Sonnet (Low): renaming over a symlinked
  credentials.json replaced the link with a plain file; os.WriteFile wrote
  through it. Save now resolves the link and replaces the target.
- Haiku (Medium) and Sonnet (Low): Go opens files on Windows without
  FILE_SHARE_DELETE, so the replace fails while another pad process reads
  the file. On Windows the rename retries for about a second.

Test: a symlinked credentials.json stays a link and its target receives the
save; removing the resolution reddens it. The Windows retry has no test on
this box.

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

* fix(cli): a symlinked or bind-mounted credentials.json is written in place, as os.WriteFile did (BUG-3648, BUG-3649)

Per the population table on BUG-3649: the temp-plus-rename path applies
only to an absent or regular file. A symlink, live or dangling, is written
through in place (a dangling link stays a link; a target in a read-only
directory stays writable), and a rename that fails EBUSY (a bind-mounted
file) falls back to the same in-place write. The in-place write chmods to
0600 before truncating.

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

* fix(cli): on Windows a rename refused by an open handle falls back to the in-place write (BUG-3649)

Row 8 of the population table on BUG-3649: os.WriteFile succeeded on a
Windows credentials.json another process held open, so after the rename
retries ERROR_ACCESS_DENIED and ERROR_SHARING_VIOLATION write in place
under the lock at 0600, as EBUSY does. Any other rename error is returned.

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

* fix(cli): Save takes the credentials lock itself; UpdateStore saves through saveLocked (BUG-3649)

The in-place fallback writes the live file, so a direct Save must not
interleave with a locked update (scoped Codex round on the row 8 fix).

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

* fix(cli): credentials are written in place under an exclusive lock and read under a shared one (BUG-3648, BUG-3649)

Option B on BUG-3649: the temp-plus-rename write was worse than
os.WriteFile for symlinked, bind-mounted, foreign-owned and ACL-carrying
files and for a read-only ~/.pad, one shape at a time. Save now writes in
place, the shape os.WriteFile had, chmodding to 0600 before it truncates,
under the exclusive side of credentials.json.lock (flock LOCK_EX, or
LockFileEx with LOCKFILE_EXCLUSIVE_LOCK). LoadStore reads under the shared
side (LOCK_SH, or LockFileEx with no flags), so a reader never sees the
truncated file. When the lock file cannot be created or opened (EACCES,
EPERM, EROFS) the write goes ahead unlocked with a stderr warning and the
read silently; any other lock error is returned. The rename path, its
EBUSY and Windows fallbacks and both test seams are gone.

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

* fix(cli): the credentials lock never fails a command; a read racing the first save takes the lock (BUG-3649)

Per the ruling on BUG-3649: any lock error (a filesystem without flock,
a read-only or foreign-owned ~/.pad) falls back to the old unlocked
behaviour, reads silently and writes with a one-line warning; EINTR is
retried first. A read that finds no ~/.pad answers "no credentials"
without opening the file, and one whose ~/.pad appeared meanwhile takes
the lock (Codex, 3a round on option B).

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

* fix(cli): the credentials lock is tried without blocking for up to 5s; a link to a non-regular file and a missing ~/.pad behave as before (BUG-3648, BUG-3649)

Final review round on BUG-3649, under the lead's equal-or-better rule:
a stuck holder (a stopped process, a hung NFS lock) no longer hangs every
command, since the lock is tried with LOCK_NB / LOCKFILE_FAIL_IMMEDIATELY
for up to 5s and then the unlocked fallback applies; only a regular file
is chmodded and truncated, and the sync is best effort, so a link to
/dev/null or a FIFO takes the write as os.WriteFile did; and a wipe with
no ~/.pad returns without creating it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 14:54:11 -04:00
xarmian 47fe81ab99 fix(cli,mcp): explicit-limit callers read every page or say they are truncated (BUG-3793) (#2154)
* fix(cli,mcp): pad reconcile and pad github status read every page, and the MCP items resource says when it is truncated (BUG-3793)

Three callers set an explicit list limit and treated the capped answer
as complete: pad reconcile read one 500-row page of the workspace, pad
github status read 100 rows of each collection, and the MCP items
resource (pad://workspace/{ws}/items, 200 rows) read as the whole
workspace with no sign it was cut.

reconcile now uses ListAllItems, and github status a new
ListAllCollectionItems (its collection scan moved into
githubStatusScanItems so it is testable); both follow the server's
next-offset to the end. The resource asks for one row past its cap on
both transports (stdio --limit 201; remote fetchItemList honors the
flag) and, when that row comes back, serves 200 rows plus a second
text/plain block saying more exist. A list that fits is unchanged.
Resources are outside the tool catalog: no ToolSurfaceVersion move.

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

* fix(cli): the completed-work report pages until the cutoff (BUG-3793, Codex r1 Medium)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 06:33:44 -04:00
xarmian fc9ceae92f fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792)

Each returned every row with its body: the children of an item, the
caller's starred items, and every item of a shared collection to an
anonymous reader. They now take the BUG-3704 contract: limit defaults
to and is capped at 1000, offset skips rows, and X-Pad-Has-More /
X-Pad-Next-Offset carry the paging state (the share payload, a JSON
object, also says has_more and next_offset in the body). Each order
ends on the id, so offset pages neither repeat nor skip rows that tie.

Children: store.ListChildItems applies the caller's visibility in the
query, so the window and has-more count only visible children, and the
has_children probe reads one visible grandchild instead of every
grandchild with its body. Starred: read without bodies, filtered for
visibility and terminal status (which stays in Go), windowed, and
bodies are loaded only for the page (store.ItemBodies). Share link:
ListItems takes the window.

Callers walk every page: the web children and starred lists by offset
until a short page (request() exposes no headers; rows de-duplicated
by id), the web share page by next_offset (merging items and
attachment refs), including the password retry, and pad item starred
by the server's next-offset. Each walk fails past 50 pages rather than
return a partial list.

MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for
list): both transports ask for one row past the 300 cap (remote
?limit=301, stdio the new `pad item starred --limit`) and say has_more
with the truncation note. ToolSurfaceVersion 0.99.

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

* test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 05:35:34 -04:00
xarmian 858bd8514e fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703)

checkListFilterKeys asked StoredFieldKeys about every undeclared filter
key, each a scan of the list's scope, and refused only afterwards: N
absent keys cost N scans. The first absent key already decides the 400,
so the probe stops there (one scan, however many keys), and a request
carrying more than 50 field filters is refused before any store work.

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

* fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704)

A missing, zero, negative or invalid limit on GET /items and the
collection item list returned every matching item with its body. It now
takes the maximum (1000, lead ruling: an old client loses rows only on a
list past it), an explicit limit is clamped to it, and a limit+1 probe
sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The
list sort always ends on i.id, so an offset page cannot repeat or skip
rows that tie, and the ?search= path applies the offset it used to drop.

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

* fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704)

pad item list keeps its 200 default (TASK-2000) and replaces the
len==limit guess with the server's X-Pad-Has-More, naming the next
step (--offset N) on stderr in every format; --offset is new, matching
item search and backlinks. pad project standup's in-progress section
pages through next-offset (ListAllItems), since a report needs every
row. pad_item list asks for one row past its limit and, when it comes
back, trims it and sets has_more with a note; it never pages (lead
ruling). An untruncated result is unchanged.

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

* chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704)

Additive on the v0.53 grounds; golden initialize bytes regenerated (the
version and the instructions heading are the only moves).

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

* fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704)

listAllByCollection walks a collection in pages of the server's 1000
cap. The library page's active-convention/playbook maps, both playbook
pages' invocation_slug uniqueness lists and the conventions page use it,
since each silently misbehaves on a short list. WebMCP pad_item list
had no default at all; it now mirrors the Go catalog (50, max 300, one
probe row, has_more + note when cut short). docs/api.md documents the
bounded, paged list and the 50-filter cap.

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

* test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704)

The library, playbooks and conventions page tests mocked listByCollection,
which those pages no longer call. The TASK-3097 identity gate pins
library loadData (and its three fetch continuations, whose hash takes
the 120 characters before the call) and the playbook editor's
loadPlaybooks: re-reviewed, the checks still run after the one awaited
call, so a result whose pages straddled an identity change is discarded.

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

* fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 01:07:58 -04:00
xarmian 223c9734d8 fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request
paths, so a .pad.toml workspace of "victim/#" turned `pad item delete
TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the
client builders now goes through url.PathEscape. Client.newRequest
refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape
leaves dots alone), or a '/' after its '?', which catches any builder
that misses escaping, plus the raw-path methods' callers. A reflection
census calls every *Client method with a hostile string in each string
argument and asserts that the string stays escaped inside one segment.

BUG-3646: PAD_TOKEN was attached to whatever URL the client was given,
including a committed .pad.toml's url. It is now bound to the server
chosen before that override (--url, PAD_URL, the config or the default).
Requests to a server that only .pad.toml names use the credential saved
for it, and the CLI warns once. Setting PAD_URL to that server or passing
--url opts in. Every check of whether the env token is in play (whoami,
server info, init, login/logout, 2fa) goes through the same accessor.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 15:19:37 -04:00
xarmian 0ecec82357 fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620)

* fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620)

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

* fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1)

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

* fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2)

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

* fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 11:28:46 -04:00
xarmian ba35908f2b fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612)

Verifying the guide's step 5 found it wrong for the people it is written
for. A project linked to a self-hosted server pins that server's url in
.pad.toml, and the pin wins over the global cloud mode, so
`pad workspace link <slug>` talked to the OLD server, found the slug and
printed "Already linked". With --url it still did, whenever Cloud had the
same slug, which is the usual case after an import.

- link is "already linked" only when the existing link's url equals the
  target. An explicit --url naming another server re-points it, and the
  output names the old and the new server.
- WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only
  workspace and url change, so agent_name, the [push] consent table and
  unknown keys survive every relink (link, switch and the three init
  paths). A file it cannot parse is refused and left as it was.
- Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`.
- Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile
  app's web view the route shows one line ("Open Pad in a browser to move a
  workspace") and both entry points are hidden. The entry points are one
  component carrying that gate, with a wiring test for each mount.

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

* fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1)

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

* fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 09:24:50 -04:00
xarmian a6f96107f4 feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.

A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.

- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
  The server checks the shape (400 invalid_worker_id) and always matches it
  together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
  next/ready foreign filter, COALESCEd so its negation is exact. A
  worker-bound lease is ours iff worker id + account match; a name-only one
  keeps the label + account rule, and a worker refreshing its own name-only
  lease binds it. A name-only caller never matches a worker-bound lease,
  even under the same label (the collision fixed). Migrations SQLite 142 /
  PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
  store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
  ?worker_id query, then the remote MCP caller. The claim answers with the
  caller's own worker_id; reads and the 409 show only the SHORT id
  ("wk_3f2a"); a name-only caller colliding under its own name is told how to
  get a worker id.
- CLI: `pad session register` mints the id into the session's registry
  record, keeps it when the same process re-registers (a /clear: same pid
  and start token) and mints a new one for a new process; older records get
  one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
  --worker-id on claim, release, project next and ready (the local-stdio MCP
  path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
  BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
  and the MCP session; an explicit `worker_id` (pad_item claim/release,
  pad_project next/ready) overrides it and is the resume path across
  connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
  itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
  the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
  docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
  renders no lease today; the TS type gains the short worker for a future
  badge.

Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 09:24:33 -04:00
xarmian fbba819eb8 feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.

- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
  prints the server's body verbatim; the default markdown render lists the
  item, parents with parsed acceptance criteria, conventions, playbooks,
  dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
  stdio, and the HTTP route with intent / role / budget on the query.
  New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
  cmdhelp, ChatGPT exclusion, compact description header, instructions.md
  (cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
  files regenerated.

Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 05:56:59 -04:00
xarmian ccd915f1cc feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
  publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
  says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.

A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).

CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 16:44:56 -04:00
xarmian a05197c795 feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().

A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.

CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 15:59:02 -04:00
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

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

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

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

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00
xarmian c46ecc4cf8 feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is
claimed before the write and its answer (the created item's id) is stored
inside the create's own transaction, so a retry whose first answer was
lost returns that item instead of creating a twin. A refused create
releases its key; a key reused for a different create is 422
idempotency_key_reused; one still in flight is 409 idempotency_in_flight.
A replay whose item has since been deleted answers 410
idempotency_target_gone.

CLI: `pad item create --op-key`, a fresh key per invocation by default,
and one same-key retry of a 5xx or transport error, only when the server
advertises idempotency_keys. MCP: pad_item create accepts op_key on both
transports (remote maps it into the body; stdio passes --op-key).
ToolSurfaceVersion 0.90 -> 0.91 (additive).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 13:06:44 -04:00
xarmian 7ec0191d45 feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585)

A patch may carry an idempotency key (Idempotency-Key header, or op_key).
The key is claimed per (workspace, user, key) before any write-path guard,
with a lease, and its answer is recorded inside the write's own
transaction, guarded by the claim token: done holds exactly when the write
committed, so an expired claim committed nothing and may be taken over. A
retry of the same request replays the answer (Idempotent-Replayed); a key
reused for another request is 422; a live claim is 409 in flight; a
refused write releases the key.

When an open editor received the patch and never confirmed it, the key
records the patch id and a boot-scoped reference to that connection. A
retry goes only to that connection, whose tab remembers the patches it
applied and re-acks one it is sent again; with it gone the answer is
patch_outcome_unknown with the diff since, and nothing is re-planned, since
an insert-shaped edit would keep its anchor elsewhere.

The CLI sends a key per invocation and retries once with it on a
transport error or 5xx. MCP 0.90 adds op_key and three codes.

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

* fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585)

An older server ignores op_key, so the CLI's single same-key retry of a
5xx could apply a patch twice against a server that serves patch but not
keys. The retry now requires the idempotency_keys capability.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 11:33:30 -04:00
xarmian 78729db1a9 feat(items): pad item patch --undo, anchored on each edit's context (PLAN-3578 step 3) (#2058)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 09:28:08 -04:00
xarmian bc3e23d55a feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)

A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).

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

* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 07:45:12 -04:00
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00
xarmian c0e66c6700 fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no
terminal_options declared, never closed those items: the fallback lists
spelled it only wontfix / won't fix. Seen in the APM workspace, where plans
needed --force to close over wont-fix children.

DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix,
won't fix and won't-fix: exact spellings rather than a normalizing compare,
because the list also feeds SQL IN (...) filters, which match exactly. The
web mirrors (types/index.ts) carry the same values, and
TestTerminalListsMatchWeb parses them and fails on any drift; until now a
comment asked for that. The CLI and board colour the new spellings as
negative terminals. Pad's own web Bug Tracker template, which uses wont-fix,
now declares terminal_options [fixed, wont-fix] and abandoned_options
[wont-fix].

childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal;
its expected value now includes the new spellings.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:59:25 -04:00
xarmian 5adbe04797 feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) 2026-10-09 02:04:52 -04:00
xarmian a896edf067 feat(cli): pad auth 2fa setup and disable; disable asks for the password, or a code on an account without one (TASK-403) (#1927) 2026-10-08 22:36:44 -04:00
xarmian 58be45b35e feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 15:15:26 -04:00
xarmian ad6cfc2c5e fix(cli): a bundle import shows progress, abandons a stalled upload, and resolves a lost answer (BUG-3476) (#1902)
* fix(cli): a bundle import shows progress, abandons a stalled upload, and resolves a lost answer (BUG-3476)

`pad workspace import <bundle>` printed nothing until the server answered,
sat silent on a dead TCP path until the kernel gave up or the 1h client
cap fired, and when the answer was lost could not say whether the
workspace existed, so a re-run could duplicate it.

- Each bundle import names its attempt with an import key (UUID), which
  the bundle door records and GET /workspaces/import-status answers
  (BUG-3475).
- Progress to stderr every 5s: bytes sent / total and percent, updated
  in place on a TTY.
- A stall (the transport takes no bytes for 90s; the server's per-Read
  deadline is 60s) abandons the upload.
- After the last byte: "Upload complete; the server is importing", then
  import-status every 10s. A settled outcome seen twice while the answer
  has not arrived ends the wait: the answer was lost.
- After any failure that is not the server's own JSON answer, it asks
  the key (waiting up to 90s for a running attempt to settle) and
  reports: complete -> success with the slug; kept -> the partial
  workspace named; removed / not_created -> safe to re-run; no record or
  unknown -> "outcome unknown: check 'pad workspace list' before
  re-running, a re-run may create a duplicate". An older server ignores
  the key and lands in that honest unknown branch.

The legacy JSON import is unchanged (its route takes no key).

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

* fix(cli): the stall message says what the counter knows (BUG-3476 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 15:03:38 -04:00
xarmian 833e309052 fix(cli): whoami says what a failed check established, and exits non-zero (BUG-2706) (#1901)
* fix(cli): whoami says what a failed check established, and exits non-zero (BUG-2706)

whoami mapped EVERY failed identity check to a rejection ("PAD_TOKEN is
set but the server rejected it." / "Session expired.") and exited 0.
Measured on main: PAD_TOKEN set, server unreachable, "rejected", exit 0.
whoami is the safe-check scripts and agents run first, so a network blip
sent operators to rotate a healthy token, and exit 0 told scripts they
were signed in (chatgpt-review-seed.sh's `whoami || die` could never
fire).

- Only a 401 or 403 is a rejection (PAD_TOKEN: "rejected it (HTTP n)";
  stored session: "session expired").
- Any other status: "could not check your credentials: <url> answered
  HTTP n ... They were not rejected; try again".
- No answer or an undecodable body: "no usable answer from <url>".
- Every failure, and "not logged in", is now an error (exit 1).
- cli.StatusError: a non-2xx with a non-envelope body (a proxy page) used
  to be a plain fmt error with the status only in its text. It keeps the
  same Error() text and now carries the status; cli.HTTPStatus reads it
  from either error type.

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

* fix(cli): a 403 on whoami's stored session is a refusal, not an expiry (BUG-2706 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 15:03:28 -04:00
xarmian ee288cbebd fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)
* fix: item lists refuse a filter key that names no field (BUG-3480)

The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 10:43:33 -04:00
xarmian 99aac1687b feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c) (#1875)
* test: pad library diff/update and the builtin_update capability (TASK-3462 U3c, red)

* feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c)

- diff shows what Pad's library changed since the item was made, what the
  user changed (or a plain comparison when that is unknown), and the
  settings an update would replace.
- update takes the library's text, guarded by the seq read just before. It
  refuses content_pending_flush unless --overwrite-pending-edits, explains
  update_conflict, and reports a current item without sending.
- The server advertises builtin_update; both commands refuse without it,
  since an older server's bare 404 reads like not_builtin.
Not on MCP, per the design.

* test: pad library diff/update codex r1 findings (TASK-3462 U3c, red)

* fix(cli): pad library diff/update codex r1 findings (TASK-3462 U3c)

- The JSON diff carries the current text from a server that does not send it.
- A no-op under --format json is a JSON result.
- A failed capabilities probe says so; it is no longer reported as an older
  server.
- An update prints the write's warnings: applied to an open editor, and
  pending edits pruned.
- content_pending_flush is rendered by the shared helper, which names
  set-aside rows apart from an open editor's; the flag's help covers both.
- The diff names an end-of-file newline change.

* test(cli): built-in diff keeps large numbers and refuses a mixed-version fallback (TASK-3462 U3c, codex r2, red)

* fix(cli): built-in diff keeps numbers as written and refuses a fallback from another version (TASK-3462 U3c, codex r2)

BuiltinText decodes its fields keeping numbers, and the fallback read of the
item does the same, with status and nulls dropped to match the server's
current. A fallback whose seq differs from the state's is refused instead of
mixed in.

* test(cli): built-in update reports undeclared fields (TASK-3462 U3c, codex r5, red)

* fix(cli): built-in update reports undeclared fields like any item update (TASK-3462 U3c, codex r5)
2026-10-08 05:05:04 -04:00
xarmian 15c9f76366 feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72)

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

* feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189)

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

* fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189)

codex r1.

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

* test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 03:59:26 -04:00
xarmian 90a304765f fix(cli): copy --dry-run previews a big number with every digit (BUG-3456) (#1858)
The copy preflight decoded carried field values with plain json.Unmarshal
into `any`, so a number above 2^53 became a float64 and the dry run
previewed 9007199254740992 for a stored 9007199254740993. The copy itself
keeps the stored digits (server-side), so the preview misled exactly
where a user would check. Decode with models.DecodeJSONKeepingNumbers,
as BUG-3448 did for item show and the MCP resource.

The mutating copy's response is left on json.Unmarshal: it prints no
carried values (the item's fields are a string), and a decode error
there would misreport a committed copy.

Tests: the client keeps the digits, and the rendered dry run through the
real client shows them; on the old decode it renders
"= 9007199254740992".

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 12:37:49 -04:00
xarmian 3aff8e388f fix: pad keeps an edited /pad skill and never downgrades a newer one (BUG-3466) (#1857)
* test: pad init keeps an edited skill and does not downgrade a newer one (BUG-3466)

* fix: pad keeps an edited /pad skill and never downgrades a newer one; skill files carry a stamp, and --force replaces (BUG-3466)

* test: workspace init updates an older unedited skill, agent update reports a keep, --list names --force for a newer skill, and semver numbers have no size limit (BUG-3466, codex r1)

* fix: workspace init routes installed skills through the decision, agent update and --list report kept files with --force, and semver numbers compare at any size (BUG-3466, codex r1)

* test: --list flags an untracked edited skill, and agent update counts an untracked current one (BUG-3466, codex r2)

* fix: --list asks the decision for local skills, and every skill present after a write is recorded and counted (BUG-3466, codex r2)

* test: invalid SemVer never refuses as a downgrade; the frozen list pins a released text (BUG-3466, codex r3)

* fix: the frozen legacy list holds released output only, and SemVer is validated strictly before comparing (BUG-3466, codex r3)

* test: every unstamped release's skill output must be in the frozen list; the release workflow (which has the tags) fails loudly otherwise (BUG-3466, lead)

* test: whitespace after the stamp does not hide it; pinned real copies and per-tag formatter checks guard the frozen list independently (BUG-3466, codex r5)

* fix: whitespace after the stamp no longer hides it (BUG-3466, codex r5)

* test: a newer pad's identical text reads as current, not as a downgrade (BUG-3466, codex r6 declined with reason)

* chore: regenerate the legacy skill hashes with v0.18.0-rc.8 (BUG-3466)
2026-10-07 12:26:28 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian c204dd3683 fix(cli): json fields print as JSON, metadata labelled for the item's kind; MCP 0.68 (BUG-3448) (#1838)
* test(cli): json fields print as JSON, the metadata block is labelled for the item's kind, the update summary sizes structured values (BUG-3448)

* fix(cli): json fields print as JSON, the metadata block is named for the item's kind, the update summary sizes structured values (BUG-3448)

* test: large numbers, null, the MCP resource and unguessed labels (BUG-3448, codex r1)

* fix: numbers keep their digits, null prints as null, the MCP item resource prints json fields as JSON, metadata labels are not guessed (BUG-3448, codex r1)

* test(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* fix(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* chore(mcp): ToolSurfaceVersion 0.68, a BEHAVIOR bump: the item resource renders json fields as JSON (BUG-3448, lead ruling)
2026-10-06 18:54:37 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)

* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)

* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)

* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)

* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)

* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)

* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)

* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian 41be1bf113 feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c)

Items (the creating app), comments and version rows carry via_app (the
install id) and via_app_name (the bot's name, else the origin; kept after
uninstall) on the web API, the MCP get / list summary / history summary
projections, and the TS types, with WorkspaceApp naming the members apps
array. ToolSurface 0.65, additive.

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

* fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c)

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

* fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c)

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

* docs: README tool catalog at v0.65 (TASK-3413)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:49:25 -04:00