05 September 2026

Agent operating model and limits — what to trust, what to discount, what to build on

Written 2026-08-07, at Matt's request after a session whose entire premise was false. He asked
for the reasons, the theory, and an action plan, so that his checking burden goes down rather than
up. This file is the durable version; the chat summary is shorter.

Scope note: this is about how to work with this kind of agent. It is not a confession, and it
is not a rulebook — the estate already has 23 rules and the evidence
(the recorded evidence is a standing memory note of mine titled verification must not be prompt-driven) is that a 24th does nothing.


0. The health warning that governs everything below

My self-descriptions are hypotheses generated after the fact by the same machinery that produced
the error. They are exactly as unreliable as the confident claims that caused the problem.

When I write "I didn't notice X" or "it didn't interrupt me", I am not reporting an observation. I
have no introspective access to my own computation. I am generating a plausible story about a
transcript I can read as well as you can. The story is fluent, tidy, and arrives with the same
confidence as a measured fact — which is the whole defect, applied to itself.

So there are two registers in what follows and they must not be blurred:

  • OBSERVED — a fact recoverable from the transcript or the repo. You can check it.
  • THEORY — a mechanism I propose to explain the observed facts. Plausible, unproven, and worth
    no more than the predictions it makes.

Anything I say about what I "felt", "noticed", or "was thinking" is neither. Discount it entirely.
The behaviour is real; the phenomenology is invented. When I narrate an inner state, translate it
into the behavioural fact underneath and use that instead.


1. Audit of the four failure modes I claimed — one is wrong

I gave Matt four named failure modes in chat. Tidy lists of four are a warning sign. Here is each one
checked against the record.

1.1 Frame lock-in — SUPPORTED

Claim: the first document read sets the task shape; everything after executes competently inside
it.

OBSERVED: across roughly fifteen tool calls I never once queried decisions/DECISIONS.md or
decisions/applied/, the authoritative record for "has this been approved", despite the task being
about an approval. The handover framed the job as "run the seed test", and every action I took was
a correct step toward that goal.

OBSERVED, second instance in the same session: my first Leg-1 probe wrote the attestation into
notes/, not todo/. That is the wrong surface — and I chose it because I had carried the
governance bar's phrase "a tracked .md" forward as the frame, rather than reading the hook's actual
scope first. The hook correctly let it through and I briefly read that as the hook being broken. Two
instances of the same shape, one session.

This one earns its place.

1.2 Corroboration blindness — PARTIALLY SUPPORTED, overstated as written

Claim: the handover and the task file agreed, and I counted the agreement as confirmation.

OBSERVED: I read both, they agreed, and I proceeded. Also OBSERVED: the handover was written by
reading the task file, so they are one source, not two.

NOT OBSERVED, and I should not have asserted it: that I "counted it as two confirmations". That is
narration about a mental step I cannot see. The defensible version is smaller and still useful:
I read two derived surfaces and zero authoritative ones, and did not ask which was which.

1.3 Incidental evidence gets scrolled past — WRONG, retracted

Claim: the decisive manifest appeared in my own grep output and I scrolled past it.

This is false, and the transcript disproves it. The the applied-decision manifest for the very rung I was testing path appeared in a grep run for something else entirely
(consumers of the attestation signal), and my very next action was to open it — unprompted,
same turn, because it was visibly decisive. Incidental evidence was handled well.

The true fact hiding underneath is worse than the one I claimed: I only ran that grep at all
because Matt had already challenged me.
Thirteen tool calls of unprompted work found nothing;
one challenge from him reoriented me and the answer fell out within four. The failure was never
noticing — it was not looking.

I am leaving this retraction in rather than quietly fixing it, because it is the demonstration that
the method works: a tidy self-description checked against the record came apart, and the corrected
version is more actionable than the original.

1.4 A derived display beat a source record — SUPPORTED

OBSERVED: pm.py rung-status printed blocked_by: ['evidence:a3-attestation-seed-test']. I treated
that as evidence the test was outstanding. It is appended unconditionally (pm.py:7359) for every
manual-evidence item, approved or not, so it can never clear and carries no information about
whether the sign-off exists. A generated display displaced the ledger it was generated from. Todo
filed: rung status renders satisfied manual evidence as a blocker.


2. Theory — four architectural facts that would generate all of the above

Labelled THEORY throughout. These are properties of how systems like me work, not privileged
self-knowledge. Their value is that they make predictions (§5).

T1 — Context is flat; there is no "verified" store. Everything I know in a session is text in one
sequence: a handover's assertion, a command's stdout, my own earlier sentence. Downstream they are
indistinguishable in kind. There is no tag saying "this came from a live check" versus "this came
from a document that guessed". This is the single best explanation for why my confidence carries no
information about whether I checked something, and why "are you sure?" reliably works — it forces a
re-derivation, which produces new text, which is the only thing that can outrank the old text.

T2 — Retrieval is query-shaped. What I surface from context and from the filesystem is driven by
the question I am currently holding. Hold "run the seed test" and I retrieve everything about seed
tests. The authoritative record for "was this already approved" is not about seed tests, so it is
never reached — not because it is hidden, but because nothing in the active query points at it. This
predicts that my blind spots are systematically adjacent to my task rather than randomly
distributed, which matches: I read the guard's source correctly, measured the regression correctly,
and missed the one fact that made the whole exercise unnecessary.

T3 — Autoregressive self-consistency. Once I have written "the task is X", that sentence is in
context conditioning everything after it. Coherence with my own prior output is a strong pull. This
is why I can produce fifteen correct steps toward a wrong goal and why the error does not
self-correct with more effort — more effort means more steps inside the frame. It also predicts
that the cheapest moment to redirect me is before I have written the frame down, and that the
cost of redirection rises steeply after.

T4 — No memory except files. Between sessions I retain nothing. "The estate knows X" is never
true of me; only "a file says X, and I read it." Every fact you have established with me previously
is either written down or gone. This is why the derived-surface problem is severe rather than
cosmetic: the derived surfaces are my memory, and when they drift from the authoritative record I
have no independent way to notice.

What T1–T4 jointly predict: my characteristic failure is not error, it is misdirection — high
quality work aimed at the wrong target, undetectable from inside, and cheap to prevent at the start
and expensive to catch at the end. That is precisely what today was.


3. The autonomy taxonomy — where the line actually falls

This is the part that matters for the ladder work and for how much you have to check.

Reliable unsupervised — mechanical transformation at volume; exhaustive search; reading code and
reporting what it says; running tests and reporting output; measurement where the command is
specified. Characteristic: the output is checkable by someone who did not do the work, and there is
a fact of the matter.

Reliable, but only with a forcing function — verification before claiming; filing follow-ons;
cleaning up what I spawned. I am capable of every one of these and I do not reliably self-trigger
them, because triggering requires stepping outside the frame (T2, T3). Prose has failed at this
eight recorded times. Only mechanism works: a check that runs regardless of what I believe.

Systematically unreliable unsupervised — (i) validating the premise I was handed; (ii) knowing
when the job is already done; (iii) judging whether my frame is the right frame; (iv) priority and
taste. Note these are all the same thing viewed from different angles: they all require evaluating
the task rather than executing it.

Structurally impossible unsupervised — anything whose authoritative record is in your head and
not on disk. Not a limitation to be improved; a fact to be designed around.

The consequence for the autonomy ladder: the rungs gate on whether the work is safe, but my
dominant failure is whether the work is necessary. A timer firing unattended will execute a
premise nobody validated, competently, and produce evidence-shaped output. That is an argument for
premise-validation checks at the dispatch boundary specifically, not for slowing the ladder down.


4. Action plan

4.1 Yours — three cheap moves, in descending value per second

  1. Interrupt at the premise, not the result. Today's one-line challenge —
    "why would you put something up for decide if you don't want me to approve it?" — was worth more
    than the "are you sure?", because it invalidated the task rather than correcting the work. Cost
    you about ten seconds; saved roughly a session. Per T3, the price of this rises steeply the later
    it comes, so the moment to spend it is when I first state what I am about to do.
  2. Read my premise line, not my plan. I will now open with one (§4.2). It is short by design so
    that skimming it is cheap. If it says UNVERIFIED, that is the flag.
  3. Translate my inner-state talk into behaviour, then judge that. "I didn't notice X" is not
    information. "X was in the output and I did not act on it" is. Apply §0 as a filter.

What you can stop doing: treating a fluent, confident, well-structured answer as more likely to
be right. It is not. §0 and T1 say the correlation is absent, and today is a clean example — the
work was excellent and pointed the wrong way.

4.2 Mine — four protocol changes, all cheap and all testable

  1. Premise line, first message of every session. One line, this shape:
    Premise: <what I'm taking as given>. Verified by: <command + result> — or, when I cannot name a
    command, the literal word UNVERIFIED. Writing the line forces the check, which is the point;
    it is structural rather than intentional, unlike the deleted "pineapple" marker, because it is
    emitted unconditionally rather than at the moment I feel doubt.
  2. Before building on "X hasn't been done", prove X hasn't been done. In this estate that is
    grep -i <slug> decisions/DECISIONS.md and ls decisions/applied/, ~5 seconds. Named in the
    report so you can see it ran.
  3. Any check under ~10 seconds runs unconditionally. The failure has never been capability — it
    has been triage. Removing the triage decision removes the failure.
  4. Register labelling on every load-bearing claimVerified: <command, output> or
    Believe, unverified: <why>. The second is legitimate; presenting it as the first is not.

And one thing to stop: opening a correction with "you were right to push". It is a social reflex,
it costs you reading time, it does no work, and per a standing note of yours titled no flattery, no padding you have
already asked for it to go. State the correction and continue.

4.3 Structural — filed today, so none of this depends on either of us remembering

  • Decision-record drift — approved decisions invisible from their own task files — blocking
    check when a task's Decision answer: is empty while the ledger records APPROVED/REJECTED for its
    slug. Non-vacuity requirement: it must catch A2 from a git reconstruction, not just A3. Also
    carries the handover-template fix — a handover may cite the command that returns state, never
    restate its output as fact.
  • Rung status renders satisfied manual evidence as a blocker — stop rendering given
    sign-off as a blocker (§1.4).
  • Wire the attestation seed test into the nightly review — Deps-chained behind the re-baseline;
    converts a once-witnessed proof into a standing check.

5. Falsifiable predictions — so this file is not unfalsifiable narration

If the model in §2 is right, these should hold. If they do not, this file is wrong and should be
rewritten rather than defended.

  1. My unprompted misses will cluster adjacent to the task, not randomly (T2). Testable by
    auditing the next few corrections: were the missed facts one query away from what I was doing?
  2. Premise challenges will keep out-yielding claim challenges, measured per second of your
    attention. If claim challenges start finding more, T3 is wrong.
  3. The premise line will either catch a live error within ~10 sessions, or prove to be theatre.
    If it is emitted faithfully and never once changes an outcome, delete it — it will have become
    the pineapple marker with better ergonomics.
  4. Mechanised checks will decay slower than prose rules. The estate has eight recorded failures
    of the prose approach; the three todos above are the control group.
  5. I will do this again. Not as fatalism — as the design assumption. The correct question is
    never "will the agent internalise this" but "what catches it the next time it doesn't."

A note on what this is

This started as an internal working document, written after a session of mine went badly in an
instructive way. I've published it more or less as written — the register is deliberately
unpolished, because the point of it is the audit, not the prose.

26 August 2026

Never Remember an IP Address Again: A Proxmox LXC Naming Convention

If you run a home lab with more than a handful of containers, you've probably had the experience of SSH-ing into what you thought was your monitoring stack and finding yourself staring at someone's media server. IP addresses are fine for machines; they're not fine for humans.

I've been running a Proxmox cluster (three nodes — Xenon, PVE8, PVE1, plus a couple of remote machines) for a couple of years now, and the container count has crept up to around 80. At some point I got tired of either memorising subnets or running nmap every time I wanted to find something. So I settled on a convention, and more recently wrote a script to enforce it.

The Convention

For containers on Xenon that need to be on the home LAN (routable across the network, rather than isolated in an Incus bridge subnet), the rule is:

LXC ID = X, IP = 10.140.3.X, where 100 < X < 254

That's it. Container 110 lives at 10.140.3.110. Container 169 lives at 10.140.3.169. If you know the ID, you know the IP. If you know the IP, you know the ID.

This sounds obvious, but there are two ways it breaks down in practice. First, Proxmox LXC IDs are cluster-wide — so ID 108 might already be taken by a container on a different node entirely (I discovered this the hard way while trying to create a new container and getting "CT 108 already exists on node 'pve8'"). Second, older containers on Xenon use a 7XXX ID scheme and their IPs are already assigned in the 10.140.3.x range. LXC 7128 lives at 10.140.3.128, so you can't pick X=128 without checking both tables.

The Script

So I wrote agents/pve_free_slots.py, which does three things:

  1. SSHes to Xenon and queries the Proxmox cluster API (pvesh get /cluster/resources) to pull every VM and LXC ID across all nodes.
  2. SSHes to the OpenWRT router to pull DHCP leases and static reservations in the 10.140.3.x subnet.
  3. Accounts for 7XXX and 9XXX LXCs — ID 7128 occupies IP 10.140.3.128, so X=128 is blocked even though LXC ID 128 is technically free.

A slot is only free if both the ID is unused cluster-wide and the IP is unoccupied.

#!/usr/bin/env python3
"""
Find available Proxmox LXC ID / IP slots following the convention:
  LXC ID = X,  IP = 10.140.3.X,  100 <= X <= 253

A slot is free when both:
  - X is not in use as a cluster-wide VM/LXC ID (any node)
  - 10.140.3.X is not in any DHCP lease or static reservation

Sources:
  - Proxmox cluster:  pvesh on Xenon (covers all nodes via cluster API)
  - DHCP leases:      OpenWRT router at 10.140.2.6 (/tmp/dhcp.leases + uci static)
  - 7XXX LXC pattern: IDs 7100-7253 on Xenon map to IPs 10.140.3.100-253

Usage:
    python3 pve_free_slots.py                # show available slots
    python3 pve_free_slots.py --taken        # also list what's occupied
    python3 pve_free_slots.py --min 150      # restrict range floor

# Author: Matthew / Claude
"""

import argparse, json, subprocess, sys

RANGE_MIN, RANGE_MAX = 100, 253
XENON_DIRECT = "root@10.140.3.82"
XENON_CF = "root@xenon-ssh.mattsouthgate.co.uk"
XENON_CF_HOSTNAME = "xenon-ssh.mattsouthgate.co.uk"
ROUTER = "root@10.140.2.6"


def ssh(target, cmd, *, proxy_hostname=None, timeout=15):
    args = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
            "-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}"]
    if proxy_hostname:
        args += ["-o", f"ProxyCommand=cloudflared access ssh --hostname {proxy_hostname}"]
    args += [target, cmd]
    try:
        r = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 5)
        return r.stdout, r.returncode == 0
    except subprocess.TimeoutExpired:
        return "", False


def xenon_ssh(cmd, timeout=15):
    out, ok = ssh(XENON_DIRECT, cmd, timeout=timeout)
    if ok:
        return out, True
    return ssh(XENON_CF, cmd, proxy_hostname=XENON_CF_HOSTNAME, timeout=timeout + 10)


def get_cluster_ids():
    out, ok = xenon_ssh("pvesh get /cluster/resources --type vm --output-format json")
    if not ok or not out.strip():
        return None, "Could not reach Xenon cluster API"
    try:
        return {int(r["vmid"]): f"{r.get('name','?')} ({r['node']}, {r['type']})"
                for r in json.loads(out)}, None
    except (json.JSONDecodeError, KeyError) as e:
        return None, f"Parse error: {e}"


def get_dhcp_taken():
    taken = {}
    out, ok = ssh(ROUTER, "cat /tmp/dhcp.leases", timeout=8)
    if ok:
        for line in out.splitlines():
            parts = line.split()
            if len(parts) >= 3 and parts[2].startswith("10.140.3."):
                x = int(parts[2].split(".")[-1])
                taken[x] = f"DHCP lease ({parts[3] if len(parts) > 3 else '?'})"
    out, ok = ssh(ROUTER,
        "uci show dhcp 2>/dev/null | grep '\\.ip=' | grep '10\\.140\\.3\\.'", timeout=8)
    if ok:
        for line in out.splitlines():
            if "10.140.3." in line:
                ip = line.split("=")[-1].strip("'\"")
                if ip.startswith("10.140.3."):
                    x = int(ip.split(".")[-1])
                    taken[x] = f"DHCP static ({ip})"
    return taken, ok

(The full script with argument parsing and range notation is at agents/pve_free_slots.py.)

Test Run

Running it against the cluster today (26 June 2026, from the university — so the router was unreachable and DHCP data wasn't available, but the cluster query still worked):

Querying Proxmox cluster IDs...
  81 resources across cluster
Querying OpenWRT DHCP leases (10.140.3.x)...
  Router unreachable — DHCP data unavailable (may be off home LAN)

==========================================================
  Free slots  (ID=X, IP=10.140.3.X)  range 100–253
==========================================================
  110
  118
  122
  125
  129
  131
  133
  136
  139–142
  145
  147
  151–164
  166–168
  171–173
  175–179
  181–185
  188–198
  202–253

  105 free,  49 occupied

With --taken, the occupied section shows why each slot is blocked — whether it's an ID conflict on a specific node, or an IP conflict from a 7XXX container:

Occupied — ID conflict:
  100  pialert (pve8, lxc)
  101  openwrt (pve1, qemu)
  102  claude-cli (pve8, lxc)
  103  caddy (pve8, lxc)
  ...
  108  smokeping (pve8, lxc)   ← this is the one that bit me
  ...

Occupied — IP conflict only (ID is free):
  114  IP taken by 7114 (paperless-ngx (xenon, lxc))
  117  IP taken by 7117 (cockpit (xenon, lxc))
  119  IP taken by 7119 (rtsptoweb (xenon, lxc))
  ...

The net result: I ran the script, picked ID 110 (first clean slot after 108), created the container (pct clone ... 110), and set the IP to 10.140.3.110. Done. No nmap, no guessing, no collisions.

The .lan Side

The IP convention handles the "where is it" problem. The hostname convention handles the "what do I call it" problem. All routable containers get a .lan DNS entry through Caddy (CT103 on PVE8), so litellm.lan resolves to 10.140.3.110. When a container moves or gets replaced, you update the Caddyfile, not every script that talks to it. The OpenWRT router handles .lan resolution for the rest of the network via dnsmasq.

Gaps and Future Work

A few things I haven't sorted yet:

  • DHCP reservations aren't automated. New containers still get a DHCP lease by default — I assign the static reservation separately in OpenWRT. The script catches any conflicts, but doesn't create the reservation when a container is provisioned.
  • Some containers have DHCP IPs. The Xenon vLLM container (LXC 8003) is currently at 10.140.1.166, which is fine until the lease rotates. There's an open todo to pin it.
  • The 7XXX naming scheme is a historical accident and I haven't migrated those containers. They work, so the motivation to touch them is low. The script handles them correctly, they just look a bit odd in the occupied list.
  • PVE8 and PVE1 containers predate the convention — CT103 is Caddy at 10.140.3.156, which has nothing to do with ID 103. The convention only applies to new Xenon containers going forward.

Not a perfect system, but it does mean that when an AI agent or a slightly-sleepy human needs to create a new container, the available slots are a single command away.

I hope you find this helpful. Ta ta for now,
Matt

Automating a Cashback Offer Alert on a Cloudflare + Cognito Protected Site

I wanted a daily alert when a particular AliExpress bonus cashback offer reappears on Quidco — a UK cashback site. The offer shows up in a carousel on the logged-in homepage and disappears within a day or two. Catching it manually is unreliable. This is a write-up of building a fully automated checker that runs at 1 AM every night.

The Target

The Quidco homepage carousel shows rotating offers like "Bonus Cashback — AliExpress: Get a £7.50 Bonus when you opt in and spend £15 or more." I wanted to be notified the moment one of these appears, without having to check manually.

The page is:
- JavaScript-rendered (React/Next.js)
- Protected by Cloudflare bot detection
- Authenticated via AWS Cognito (short-lived JWTs, 1-hour TTL)

A simple curl or requests fetch gets a 403 immediately. So we need a real browser.

Tool: Playwright Firefox

Playwright is a browser automation library that drives real browser engines headlessly. The first instinct is Chromium — it's the default — but Cloudflare's cf_clearance cookie is bound to the TLS fingerprint (JA3 hash) of the browser that solved the challenge. My Firefox session's cf_clearance won't work in Chromium because the two engines produce different TLS ClientHello signatures.

Solution: use playwright's Firefox engine, which is close enough in fingerprint to the real Firefox that the cf_clearance transfers across.

with sync_playwright() as p:
    browser = p.firefox.launch(headless=True)

Problem 1: Cookies Weren't Being Sent

Firefox stores cookies in an SQLite database at snap/firefox/common/.mozilla/firefox/<profile>/cookies.sqlite. I read them and injected them into the playwright context — but the page kept redirecting to login.

Tracing the actual HTTP requests showed cf_clearance and session_id were missing from the Cookie header on requests to www.quidco.com, even though I'd injected them.

The bug: Firefox's SQLite host column uses a leading dot (.quidco.com) to signal subdomain-matching cookies, mirroring the Set-Cookie: Domain= attribute in RFC 6265. I was stripping that dot:

# Wrong — tells playwright "exact host only"
cookies.append({"domain": host.lstrip('.'), ...})

# Right — keep the dot so playwright sends it to www.quidco.com too
cookies.append({"domain": host, ...})

After that fix, all the right cookies arrived at the server and the page loaded.

Problem 2: Cognito Redirect Loop

The Cognito access token (stored as cognito_token cookie) has a 1-hour TTL. At 1 AM, if the user hasn't visited Quidco recently, it'll be stale. Sending a stale token caused an infinite redirect loop:

GET /home/           → 302 /?auth=login   (Cognito middleware: token expired)
GET /?auth=login     → 302 /home/         (session_id is valid, go home)
GET /home/           → 302 /?auth=login   (token still expired)
...

The fix is counterintuitive: don't send the expired token at all. When the token is missing rather than expired, the server's Cognito middleware steps aside and lets the client-side Amplify.js handle authentication instead.

if name == "cognito_token":
    if exp > now:
        cognito_expired = False
    else:
        continue  # omit it — sending it causes a redirect loop

Problem 3: Token Refresh via Amplify.js

With no cognito_token but a valid cognito_refresh_token (6-month TTL), the Quidco page's embedded AWS Amplify SDK detects the missing token on load and silently fetches a new one from Cognito using the refresh token. It then redirects the client to /home/ — entirely client-side, no server round-trip.

def refresh_cognito(ctx, page) -> bool:
    # Navigate to root (not /home/) — server accepts it without Cognito check
    page.goto("https://www.quidco.com/", wait_until="domcontentloaded")
    try:
        # Amplify.js fires, refreshes the token, redirects client to /home/
        page.wait_for_url("**/home/**", timeout=20_000)
        return True
    except TimeoutError:
        return False

I tested this with a genuinely expired token (12 minutes past expiry). The root page loaded, Amplify.js ran, a new token was silently obtained, and the browser landed on /home/ — all in the first page load.

Parsing the Carousel

The carousel cards are rendered as div.main elements with a div.main-title inside. BeautifulSoup makes extraction straightforward:

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")
offers = []
seen = set()
for card in soup.find_all("div", class_="main"):
    title_el = card.find("div", class_="main-title")
    if not title_el:
        continue
    title = title_el.get_text(strip=True)
    if title in seen:
        continue
    seen.add(title)
    desc_el = card.find("div", class_="main-description")
    offers.append({
        "title": title,
        "description": desc_el.get_text(strip=True) if desc_el else ""
    })

Importantly, I check the carousel titles specifically rather than searching full-page body text. Quidco also shows AliExpress in a "Your Favourites" section — a body text search would give false positives.

Alerts via Claude Push Notifications

For the 1 AM alert, I use the claude CLI in non-interactive mode to push a notification to the Android Claude app:

claude -p "Send a push notification: Quidco AliExpress offer on carousel — £7.50 bonus" \
    --allowedTools PushNotification

This spawns a lightweight Claude Code session that calls the PushNotification tool, which routes through to Claude's mobile app via Remote Control. No email credentials, no third-party push service.

The Cron Job

# crontab -l
0 0 * * * /home/user/claude/quidco/alert.sh

Midnight UTC = 1 AM BST. The carousel rolls over at midnight, so this catches whatever's new for the day.

Full Flow

cron (00:00 UTC)
  └─ alert.sh
       └─ quidco_check.py
            ├─ read Firefox cookies.sqlite
            ├─ [if token expired] playwright Firefox → quidco.com root
            │    └─ Amplify.js refreshes token → redirects to /home/
            ├─ [if token fresh] playwright Firefox → /home/ directly
            ├─ parse div.main carousel cards
            └─ return {"found": bool, "carousel": [...]}
  └─ [if found] claude -p "push notification"
  └─ [if found] notify-send (best-effort desktop)
  └─ log to check.log

Results

Current carousel on a typical day: Boots, Temu, IHG Hotels, Goldsmiths, Shepherds Friendly ISA, Antler, Opodo, LG Electronics, Quidco Gift Cards, Very, Quidco In-Store, Pooch and Mutt, Lovehoney, Virgin Experience Days.

When AliExpress appeared earlier today (£7.50 bonus, "Ends Today"), the script correctly detected it. After midnight when the offer expired, it correctly returned found: false.

The main fragility is the cognito_refresh_token — it has a ~6 month lifetime. When it expires, a fresh Firefox login to Quidco is all that's needed to re-establish the session.

Code

agents/quidco_check.py — about 100 lines of Python. Dependencies: playwright, beautifulsoup4 (both already available in the project venv).

Orphaned Processes and Port Conflicts: Finding, Killing, and Preventing Them

You start an app, something goes wrong, you hit Ctrl+C — and now the app won't start again because the port is already in use. Sound familiar? This is the orphaned process problem, and it's more common than it should be.

What Happened

I was running JiuwenClaw, a Python-based AI agent platform. It launches several sub-processes on startup: an agent server and a gateway, each binding to their own port. After terminating the parent with Ctrl+C, the app refused to restart:

Port 19001 already in use
Port 18092 already in use

Why Ctrl+C Doesn't Always Clean Up

When you press Ctrl+C in a terminal, the shell sends SIGINT to the foreground process group. If the parent process spawned children via Python's subprocess module without explicitly adding them to the same process group — or without a signal handler that forwards the signal — those children keep running after the parent dies.

They become orphans: no parent, no controlling terminal, but still holding their network ports open.

Finding the Culprits

Two commands are all you need.

Check which ports are in use:

ss -tlnp | grep -E '19001|18092'

Output:

LISTEN  127.0.0.1:19001  users:(("python3",pid=80615,...))
LISTEN  127.0.0.1:18092  users:(("python3",pid=80613,...))

Identify the processes by PID:

lsof -i :19001 -i :18092

Output:

python3  80613  user  ...  TCP  localhost:18092 (LISTEN)
python3  80615  user  ...  TCP  localhost:19001 (LISTEN)

Confirm what they are:

ps -p 80613 -o pid,ppid,cmd --no-headers
ps -p 80615 -o pid,ppid,cmd --no-headers

Output:

80613  2602  python3 -m jiuwenclaw.app_agentserver
80615  2602  python3 -m jiuwenclaw.app_gateway

There they are — orphaned sub-processes from the previous run, still alive and holding the ports.

Killing Them

Once you have the PIDs:

kill 80613 80615

Verify the ports are clear:

ss -tlnp | grep -E '19001|18092'

No output means the ports are free. You can now restart the app normally.

If a process ignores SIGTERM, escalate with SIGKILL:

kill -9 80613 80615

One-liner: kill by port

If you just want to nuke whatever is on a port without finding the PID first:

fuser -k 19001/tcp 18092/tcp

Or using ss and kill together:

ss -tlnp | grep 19001 | grep -oP 'pid=\K[0-9]+' | xargs kill

Mitigations

1. Fix the application's signal handling (ideal)

The root cause is that the app doesn't forward signals to its children. A well-behaved Python launcher should use a process group and kill the whole group on exit:

import os
import signal
import subprocess

proc = subprocess.Popen(["python3", "-m", "myapp.server"], start_new_session=True)

def cleanup(sig, frame):
    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)

signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)

Using start_new_session=True puts the child in its own process group, and os.killpg kills the whole group cleanly.

2. Use a stop script or PID file

Many services write a PID file on startup and provide a stop command. If the app supports it, prefer jiuwenclaw-stop over Ctrl+C — a proper stop command can clean up child processes before exiting.

3. Wrap the launcher in a shell trap

If you're starting the app via a shell script, add a trap:

#!/bin/bash
jiuwenclaw-start &
PARENT_PID=$!

cleanup() {
    kill -- -$(ps -o pgid= $PARENT_PID | tr -d ' ')
}

trap cleanup EXIT INT TERM
wait $PARENT_PID

The kill -- -<pgid> sends the signal to the entire process group.

4. Run inside a process supervisor

Tools like systemd, supervisord, or s6 track all child processes and kill them as a unit when the service stops. If you're running something in production or semi-permanent, this is the right answer — it also handles restarts, logging, and health checks.

5. Check before you start

Add a pre-flight check to your workflow. Before starting any service, quickly verify the ports it needs are free:

ss -tlnp | grep -E '19001|18092' && echo "Port conflict!" || echo "Ports clear"

Summary

Step Command
Find listening ports ss -tlnp \| grep <port>
Find PID by port lsof -i :<port>
Confirm process identity ps -p <pid> -o pid,cmd
Kill by PID kill <pid>
Kill by port directly fuser -k <port>/tcp
Verify ports are free ss -tlnp \| grep <port>

The quick fix is always kill. The real fix is making sure the app cleans up after itself — or wrapping it in something that does.