Counts the SSH connections Claude opens with plink in a session, shows the count in the status line, and refuses new ones past a limit

Mokaair is a mock-first, API-first travel comparison MVP. It combines flights, hotels, activities, and transportation into complete trip plans and explains the trade-off between the cheapest, balanced, and comfortable choices.
Visitors can choose mocha, lagoon or forest, each with light/dark variants, independently of the system brightness setting. Preferences are shared across headers and My space; existing custom planner palettes remain available. Language controls are at the top of desktop/mobile headers. Discovery cards show all reviewed topics, while reading details separate articles and videos and omit empty information sections.
/admin/site-pages manages privacy, terms, about and contact in five languages using drafts, previews, complete version history and explicit publication. Migration 0069_site_pages creates storage only. An administrator can initialize missing first drafts without overwriting existing documents. Initial drafts remain unpublished until the owner supplies and verifies operating/contact/retention/legal details and an effective date. Publishing requires settings.manage, a matching version and an explicit reason and confirmation. The public API serves only published locale-specific versions with Cache-Control: no-store; it never falls back to drafts. See the workflow and verification notes.
Merchant discovery also supports independent photogenic / arts & culture style filters. Administrators add source-backed style reviews under /admin/foods; approving a label never bypasses the existing merchant/location publication gates. See merchant styles for migration 0061, the reviewed five-shop candidate batch, and the explicit dry-run/import workflow.
The opt-in community adds five-language public profiles, moderated photo posts, independent public itinerary snapshots, free private copies, follows, comments, private collections, mutual-follow messages and in-app notifications. Reviewed pet rules are kept separate from traveller reports; unknown conditions never count as compatible. Existing travel tools and usage charging remain available.
COMMUNITY_ENABLED=false is the default. Administrators manage API-enforced switches and limits at /admin/community, and verified place rules and evidence at /admin/pet-friendly. A saved database setting overrides the environment default. Do not activate the public community until its acceptance checklist, storage, mail, translation capacity, moderation staffing and public legal/contact pages have been completed. See the community operations guide for setup, privacy boundaries, acceptance commands and outstanding launch gates.
The public product name is Mokaair. The primary wordmark is typography-only: Moka uses mocha brown (#6B4A3A) and air uses deep teal (#0D6B68) on a cream (#F7F1E8) background. Reusable SVG and PNG artwork lives in apps/web/public/brand; Next.js favicon and Apple icon assets live in apps/web/app. Internal database, package, service, and environment identifiers retain their existing travel_scanner / travel-scanner names for compatibility.
The desktop and mobile headers provide System, Light, and Dark appearance choices. The preference is applied before the page paints, stored only in the current browser as mokaair-theme, and follows operating-system changes while System is selected. It does not require an account or sync between devices. The trip planner's Ocean, Sunset, and Lavender accent themes remain available; dark mode adapts their surfaces and contrast without replacing the selected accent.
Trip search supports an explicit mock development mode, an Amadeus-backed test or live mode, Skyscanner Flights Live Prices, and Duffel Offer Requests for approved partners. FlightAware supplies status rather than fares, while Google Travel Impact Model supplies a consistent per-passenger emissions figure. Production never falls back to mock or supplier test prices when credentials are absent. The experimental airline crawler remains a separate, non-bookable public-fare research surface.
Public pages are indexable in five languages, with per-page canonical and hreflang, a robots.txt, a runtime sitemap of up to 365 URLs and schema.org markup. Closed or unavailable features leave the sitemap; independently published site documents keep self canonicals without advertising unverified translations. Moderated listings remain uncached across requests, and food pages server-render results for their actual URL filters. docs/seo.md records which routes are indexable and why, the rules a change must not break, and what to do when adding a new public page.
apps/web: Next.js App Router frontend and same-origin BFFapps/api: FastAPI modular monolith, RQ worker, models, migrations, teststasks/: the shared backlog of unfinished workarchitecture.md: boundaries and data flowdocker-compose.yml: API, worker, PostgreSQL, and RedisWork that is known but not yet done lives in tasks/, one file per task, with npm run tasks -- list as the overview. It is a shared queue: a person and several AI agents can each take a batch from it at the same time, because a task is claimed by name before it is started and declares every path it may change, so the tool refuses to hand two agents work in the same files.
npm run tasks -- list # everything unfinished
npm run tasks -- next # a task nobody else is on
npm run tasks -- claim <id> --owner <your-name> # take it
npm run tasks -- new --title "..." --area web --scope apps/web/components/alerts
tasks/README.md documents the fields, the statuses and the handover rules; npm run check:tasks enforces them in CI.
Copy .env.example to .env, then run infrastructure and API. Compose runs alembic upgrade head in a one-shot migration service before API and worker startup, and /ready remains unavailable when the database revision is stale:
docker compose up --build postgres redis migrate api worker
To run the six-hour flight/hotel price monitor and LINE delivery queue locally, also start alert-scheduler and alert-worker. LINE account linking requires a Messaging API channel and the environment values documented in docs/line-price-alerts.md.
Run the frontend separately:
npm install
npm run dev:web
Open http://localhost:3000; API docs are at http://localhost:8000/docs.
Production starts only with an explicit HTTPS origin, secure cookies, separate random APP_SECRET_KEY and SETTINGS_ENCRYPTION_KEY values, and password-protected PostgreSQL and Redis URLs. Set POSTGRES_PASSWORD and REDIS_PASSWORD, then use matching URL-encoded credentials in DATABASE_URL and REDIS_URL; never reuse the development travel password. The API schema and documentation routes are disabled in production, while /health and /ready remain available on the loopback-bound API port. TRUST_PROXY_CLIENT_IP=true assumes the bundled web BFF is the only API caller; any replacement edge proxy must discard incoming forwarding headers and set the client IP header itself.
docker compose -f docker-compose.prod.yml up --build -d
That command builds and starts the web and API images together, and the web build assumes it. The usage catalog is where the two can visibly drift: apps/web/lib/usage-catalog.ts lists every metered operation, and a web build that knows an operation the running API does not price yet charges that one operation the default cost of one use and logs the missing key when the page loads, instead of switching every metered surface to its unavailable state. Deploy the API first or both together; never the web alone ahead of an API change that adds an operation.
The production Compose file runs API processes as non-root users, drops Linux capabilities, and rejects startup when required secrets are missing or unsafe.
/admin/deployments can deploy only the latest origin/main commit whose push run of the CI workflow succeeded. The feature is off by default. Do not enable it until this version has been deployed manually and the restricted host agent has passed preflight. Installation and host directory details are in ops/deployer/README.md.
The API container receives only the agent Unix socket directory as a read-only mount; it never receives a Git checkout or the Docker socket. The host agent account itself belongs to the docker group and is therefore root-equivalent on the host; the trust boundary is documented in ops/deployer/README.md. Requests are timestamped, single-use, and HMAC authenticated. The host agent pins the repository, branch, workflow, Compose project name, release directories, and health endpoints. It builds SHA-tagged images while the prior services run, requires a PostgreSQL custom-format backup before migration, then requires three consecutive API/Web health checks. A failed activation returns to the previous application images without downgrading the database. Keep migrations backward compatible.
Administrators with deployer or owner may inspect deployment history. Only one of those administrators whose email also appears in DEPLOY_ADMIN_EMAILS receives can_deploy=true. Starting a deployment requires the current password and DEPLOY <7-char-SHA>. Set the same random 32+ character DEPLOY_AGENT_HMAC_KEY in the API runtime environment and the root-owned agent environment. The browser cannot select a branch, tag, repository, command, or historical version. Agent upgrades, database restores, and manual rollback remain host administrator actions.
Create an account in the UI to receive the currently configured number of free, non-expiring uses. To grant a usage pack locally before online checkout is available:
cd apps/api
uv run python -m app.cli add-usage-package --email you@example.com \
--package PACK_30 --reference local-test-001
After applying the database migration, grant an existing account administrator access and open the unified operations console at http://localhost:3000/admin. User, audit, and safe database operations are at http://localhost:3000/admin/users, http://localhost:3000/admin/audit, and http://localhost:3000/admin/database; existing settings remain at http://localhost:3000/admin/usage-settings or http://localhost:3000/admin/system-settings or http://localhost:3000/admin/layout-settings or http://localhost:3000/admin/settings; deployment allowlisted administrators also see http://localhost:3000/admin/deployments:
cd apps/api
uv run python -m app.cli set-admin --email you@example.com
# or create the first administrator without the public registration form
uv run python -m app.cli create-admin --email you@example.com
The legacy CLI grant maps to support, content, and operations capabilities; it does not grant deployment or database-maintenance access. Use --revoke to remove only that legacy grant; explicit database, deployment, owner, or other UI assignments are preserved. ADMIN_EMAILS is also accepted as a comma-separated bootstrap or recovery allowlist and gives an existing account the immutable effective owner role; remove the address from that environment value before revoking its access. Addresses listed in ADMIN_EMAILS, DEPLOY_ADMIN_EMAILS, or DATABASE_ADMIN_EMAILS cannot self-register through the public form (admin_email_reserved): create those accounts with create-admin, or register them before adding them to the allowlist, so that an attacker cannot claim an administrator address first. Signing out revokes the presented access token immediately. The desktop and mobile headers use the same /auth/me result and expose the administration link only to accounts with an effective administration role. Navigation is returned by the server from /api/v1/admin/bootstrap; every administration API independently enforces its required capability.
The member page keeps filters, pagination, and the selected account in the URL. It shows login methods, verification, activity, roles, available/reserved uses, timed (up to 90 days) or permanent suspension, session revocation, and scheduled privacy erasure. Role changes, permanent suspension, and erasure use a five-minute password step-up, explicit confirmation, reason, and idempotency key. Erasure has a 24-hour grace period. Non-owner roles may expire; owner is a permanent recovery role and cannot receive an expiry. Administrators cannot suspend, demote, or erase their own active session; environment-designated accounts and the final usable owner are protected. Manual usage changes require a reason and an idempotency key. Every change is a new usage_ledger entry plus an administrator audit event; deductions cannot reduce the balance below in-flight reservations. Database-backed administrators cannot adjust their own balance, so a second administrator must authorize that operation. Administrators whose email is currently listed in ADMIN_EMAILS may increase or deduct their own balance, including accounts that also hold the database-backed role; these self-adjustments use the same ledger, audit, and reserved-balance safeguards.
Password-only and social-login administrators use the same step-up boundary. An SSO-only administrator can establish their first local password through password recovery; configure and verify COMMUNITY_SMTP_HOST plus COMMUNITY_MAIL_FROM before granting that account an operational role.
Database maintenance is read-only by default. To enable verified manual backups and controlled ANALYZE, the actor needs database_operator or owner, must be listed in DATABASE_ADMIN_EMAILS, and the host must set ADMIN_DATABASE_MAINTENANCE_ENABLED=true with a healthy deployment agent. The UI never accepts SQL, exposes table rows, downloads/restores backups, or runs VACUUM FULL, REINDEX, or arbitrary migrations. See docs/admin-operations-center.md for the role matrix, safety boundary, and staged rollout.
The plans and usage page manages the registration trial, public one-time usage packs, and the cost of all 12 metered operations. Trial grants accept 1–10,000 uses. Public packs require names in Traditional Chinese, Simplified Chinese, English, Japanese, and Korean; each pack contains 1–100,000 uses, costs NT$0–10,000,000, has an explicit display order, and can be archived or restored. Pack codes are generated once and remain immutable. At most one active pack is featured, and archived packs remain available to historical ledger references. This catalog intentionally does not process payments or issue purchased packs.
Each operation cost accepts 0–100 uses. A zero-cost operation is still reserved idempotently and writes a successful zero-amount ledger entry, but leaves the member balance unchanged. Changes apply immediately to new operations and new registrations. Existing balances, ledger entries, prior package grants, and in-flight reservations are not rewritten: every reservation snapshots the cost that was effective when it began. The public, uncached GET /api/v1/usage-catalog?locale=... endpoint exposes only the effective trial grant, active localized packs, and operation costs. The existing /api/v1/plans endpoint remains available for compatibility. Administrative changes are stored in admin_audit_logs with before/after values and the actions registration_trial_updated, usage_operation_costs_updated, usage_package_created, usage_package_updated, or usage_package_archived.
The system settings page manages public registration, runtime modes and timeout or circuit-breaker protection. REGISTRATION_ENABLED=true is the environment default. Saving the public-registration switch stores registration_enabled in the existing provider_configs.runtime JSON row; that database value takes priority and applies immediately to later requests. Turning it off blocks every new self-registration, including addresses in ADMIN_EMAILS, without affecting existing account login, password changes or administrator features. The public GET /api/v1/auth/registration-status response is never cached, and failed status checks do not expose a usable registration form.
The layout management page controls the public Hotspots, Trips, Price Alerts, Flight Status, Airline Fares, and Pricing surfaces. Their environment defaults are HOTSPOTS_ENABLED, TRIPS_ENABLED, ALERTS_ENABLED, FLIGHT_STATUS_ENABLED, AIRLINE_FARES_ENABLED, and PRICING_ENABLED; all default to true. Saving creates or updates the existing provider_configs.layout JSON row, whose values take priority over the environment immediately. A disabled feature is removed from desktop and mobile navigation and related Web actions, and direct visits show a localized paused page. APIs and stored data remain available, and read-only /share/[token] links are unaffected. The public GET /api/v1/runtime/site-visibility endpoint returns only the six effective booleans with Cache-Control: no-store; failed checks hide controlled entries and show an unavailable state. Layout changes use the layout_settings_updated audit action with the operator, fields that actually changed, and resulting visibility values.
Food merchants are browsed by city, sub-city area (商圈) and site-wide cuisine category. Areas (food_areas, seeded 1:1 from each destination profile's areas) and categories (food_categories, 18 seeded) are managed under /admin/foods; merchants carry an optional area plus one or more categories, and publishing a merchant requires at least one category. Seeds only fill gaps: anything an administrator sets or clears stays that way. After deploying a release that adds taxonomy data, run python -m app.cli seed-foods inside the API container so the tables are populated without waiting for the hotspot collector, which only runs under the hotspots compose profile.
The API and keys page separately manages encrypted credentials for Google Maps, NAVER Maps, Ekispert, ODsay, Amadeus, Skyscanner, Duffel, FlightAware, Google Travel Impact, NAVITIME and affiliate providers. Desktop uses keyboard-accessible, horizontally scrollable provider tabs, mobile uses a provider selector, and only the active provider is rendered. Unsaved input remains intact while switching providers, and recent administration activity has its own tab. The page also shows each provider's last-24-hour request and failure counts. Changes take effect for API and worker requests without rebuilding the web image. Provider connection checks and configuration changes are recorded in admin_audit_logs, without secret values. System setting changes use the system_settings_updated action and record only the changed field names and effective registration result. Responses only include whether a key exists, its source, and a masked suffix.
Google, LINE, and Apple member login are configured on the same API and keys page. Each provider remains hidden until it is both enabled and fully configured. Exact callback URLs, provider-console setup, linking rules, and security behavior are documented in docs/social-login.md.
Set a stable, randomly generated SETTINGS_ENCRYPTION_KEY in production before saving credentials. It is used to derive the Fernet key for database values; changing it makes existing encrypted settings unreadable. APP_SECRET_KEY is only a backwards-compatible fallback. Restrict the Google server key by API and server egress IP. Restrict the browser Embed key by API and the production HTTP referrer. Do not commit either value.
The management APIs are under /api/v1/admin/users, /api/v1/admin/usage-settings, /api/v1/admin/provider-settings, and /api/v1/admin/deployments; the safe runtime browser configuration is served separately from /api/v1/runtime/public-config and /api/v1/runtime/site-visibility. Environment variables remain the fallback when no database override exists, and disabling a provider never silently enables mock pricing in production.
cd apps/api
uv sync
uv run alembic upgrade head
Migration 0039_localized_names adds the per-locale label columns empty. Run the backfill once afterwards so stops that were already planned re-label themselves when the traveller switches language (rows the traveller renamed are left alone):
uv run python -m app.cli backfill-trip-item-names --dry-run
uv run python -m app.cli backfill-trip-item-names
cd apps/api
uv run ruff check .
uv run mypy app
uv run pytest
cd ../..
npm run lint:web
npm run typecheck:web
npm run test:web
npm run build:web
npm run test:e2e --workspace @travel-scanner/web
CI also runs an unmocked first-party smoke journey with PostgreSQL, Redis, the FastAPI service, RQ worker, and Next.js running together. Only the external travel provider is pinned to deterministic mock mode. The journey runs in desktop Chromium and a Pixel 7 viewport and covers guest recommendations, safe sign-in return, progressive search, saving a trip, and full price-alert management.
Further sections below document providers, usage packs, orchestration, and optimization as those modules are introduced.
Registration trial uses, the public inactive-checkout catalog, and the cost of each metered operation are configured on the plans and usage administration page. The initial catalog contains packs of 10 uses for NT$199, 30 for NT$499, and 100 for NT$1,299, and every operation initially costs one use. Uses stack and never expire.
The configured cost is reserved while work is in flight and charged only when a usable result exists. Empty results and failures release the reservation and create a visible zero-charge record. The append-only PostgreSQL usage_ledger records grants, charges, releases, migrations, and adjustments; the usage_reservations unique key prevents duplicate charges. Members can review these records and their reference numbers at GET /api/v1/usage/history and in the account page.
Implement the relevant protocol in apps/api/app/providers/base.py, normalize every response into providers/schemas.py, then register the adapter with the search orchestrator. Keep credentials in environment-backed settings and never return provider-specific payloads or secrets to the client.
The built-in Mock Provider generates stable TPE/Japan examples from a hash of the request. Every result includes is_mock, retrieval time, expiry time, and a non-bookable example URL.
For provider-backed search, set TRAVEL_PROVIDER_MODE=live together with AMADEUS_CLIENT_ID, AMADEUS_CLIENT_SECRET, and AMADEUS_ENV=test or production. GOOGLE_MAPS_API_KEY is optional and enriches hotels, activities, photos, opening hours, route estimates, and saved-trip weather. Secrets belong in the runtime environment and must never be committed. GET /api/v1/providers/status reports whether live, test, mock, or disabled data is active, plus the selected and fallback provider for each search module.
Flights can be selected independently with FLIGHT_PROVIDER_MODE=auto|skyscanner|duffel|amadeus|mock|disabled. With FLIGHT_SEARCH_STRATEGY=hybrid, auto mode queries Skyscanner, Duffel, then Amadeus until at least FLIGHT_MIN_RESULT_COUNT unique itinerary
hooks/register.ts 78 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// The host stopped answering SSH after about 40 connections
5// in one session (2026-09-04), so the budget stops short.
6const LIMIT = 30
7const WARN_AT = 20
8
9// plink in command position: a bare or quoted path to it,
10// followed by an option
11const PLINK = /(^|[\s"'/\\;&|(])plink(\.exe)?["']?\s+-/i
12
13const count = atom(
14 { plugin: 'plink-budget', key: 'count' } as const,
15 0,
16)
17
18const refusal = (used: number) =>
19 `plink-budget: this session has already opened ${used} ` +
20 `SSH connections (limit ${LIMIT}); the host may lock SSH ` +
21 'out after too many. Put the remaining remote commands ' +
22 'into one script and tell the person, who can type ' +
23 '/plink reset.'
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await $.command.register({
28 name: 'plink',
29 description: '這個 session 開了幾次 SSH 連線',
30 })
31
32 return next(e)
33 })
34
35 on('command.run', { command: 'plink' }, async ($, e) => {
36 if (e.args.trim() === 'reset') {
37 await update($, count, () => 0)
38 $.ui.status(undefined)
39
40 return { text: 'SSH 連線次數已歸零' }
41 }
42
43 const used = await read($, count)
44 const text =
45 `這個 session 已經開了 ${used} 次 SSH 連線,` +
46 `上限 ${LIMIT} 次`
47
48 return { text }
49 })
50
51 for (const tool of ['Bash', 'PowerShell'] as const) {
52 on('tool.call', { tool }, async ($, e, next) => {
53 if (!PLINK.test(e.command)) {
54 return next(e)
55 }
56
57 const used = await read($, count)
58
59 if (used >= LIMIT) {
60 return { deny: refusal(used) }
61 }
62
63 await update($, count, n => n + 1)
64 $.ui.status(`plink ${used + 1}/${LIMIT}`)
65
66 if (used + 1 === WARN_AT) {
67 $.ui.toast(`SSH 連線已經 ${WARN_AT} 次,上限 ${LIMIT} 次`)
68 }
69
70 return next(e)
71 }).catch(($, e, next) =>
72 next.called
73 ? next(e)
74 : { deny: 'plink-budget: counter failed; not run.' },
75 )
76 }
77}
78types/index.d.ts 8 lines1export type PlinkCount = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'plink-budget': { count: PlinkCount }
6 }
7}
8