Merge pull request #659 from AIOSAI/dev

prax log watchdog covers branch logs/ dirs — .jsonl runaway growth caught (built by @prax). Root cause: rotation was .log-hardcoded and .jsonl writers are raw open('a') appenders bypassing prax; the watchdog safety net only scanned system_logs. Now scans all src/aipass/*/logs/ for .log+.jsonl (WARN 1MB / CRITICAL 10MB unrotated), enforce truncates to last 5000 lines, log-audit shows system + branch scopes. 11 new tests, 947 suite green. Offender writers (hooks 63MB engine.jsonl, backup 31MB operations.jsonl, trigger 7MB medic log) routed to owners separately.
This commit is contained in:
AIPass
2026-07-11 22:09:27 -07:00
committed by GitHub
283 changed files with 15759 additions and 2606 deletions
+1
View File
@@ -6,5 +6,6 @@
!README.md
!PROMPT_STYLE.md
!project_CLAUDE.md
!project_AGENTS.md
!project_hooks.json
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
+15 -1
View File
@@ -6,7 +6,8 @@
"presence_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
"matcher": ""
"matcher": "",
"provider_wired": false
},
"identity_injector": {
"enabled": true,
@@ -62,6 +63,11 @@
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
"matcher": "Bash"
},
"registry_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
},
"engine_test_sound": {
"enabled": false,
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
@@ -138,5 +144,13 @@
"matcher": "",
"timeout": 120
}
},
"SessionStart": {
"cadence_reset": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
"matcher": ""
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# {name}
Agent workspace powered by AIPass.
# Startup protocol
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
Use drone commands for all operations. Never raw git, gh, or file access when drone provides it.
# Memories
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
+9 -1
View File
@@ -1,5 +1,5 @@
{
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here.",
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here. NOTE: git_gate is enabled by default — it enforces git via drone to prevent state conflicts. To disable for your project, set git_gate.enabled to false below (this won't break other hooks).",
"hooks_enabled": true,
"UserPromptSubmit": {
@@ -92,6 +92,14 @@
}
},
"SessionStart": {
"cadence_reset": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
"matcher": ""
}
},
"PreCompact": {
"pre_compact": {
"enabled": true,
+3 -1
View File
@@ -1,6 +1,6 @@
# AIPass — Kernel
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
<!-- .aipass/tier0_kernel.md — Tier 0, injected every 5 turns (cadence period 5) + on every fresh context (new chat / clear / after compact). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`.
@@ -13,6 +13,8 @@ You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your
- `drone @agent` — bare → the agent's live self-map.
- `drone systems` — list every agent.
`aipass` is the one exception — the user's own front-door CLI and concierge (onboarding, `doctor`, OS/system help). Run `aipass` / `aipass --help` directly, **never `drone @aipass`** (drone can't resolve it). Serves humans, not agents.
The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`.
# Don't get lost
+2 -2
View File
@@ -41,7 +41,7 @@ src/aipass/<name>/
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
- @aipass — the user's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — reads, never writes.
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
@@ -55,7 +55,7 @@ src/aipass/<name>/
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (planned). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (live, per-file mirror — slow on huge file counts, respect `.backupignore`). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
# Daily commands
+1 -1
View File
@@ -44,7 +44,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
- Update their execution logs, status, decision logs with current state
- If a plan was completed, note it (but don't close — the user does that)
## 3. Git State
## 3. Git State (Devpulse only)
- Run `git status` — report uncommitted changes
- If there's a logical commit waiting, suggest it (don't commit without asking)
+6
View File
@@ -118,6 +118,12 @@ src/aipass/*/apps/integrations/**
!src/aipass/commons/apps/handlers/artifacts/
!src/aipass/commons/apps/handlers/artifacts/*.py
# hooks boot-shim installer — must ship so fresh clones can install the claude()
# shell function. Collides with the blanket tools/ ignore (line 51).
!src/aipass/hooks/tools/
src/aipass/hooks/tools/*
!src/aipass/hooks/tools/install_boot_shim.sh
# CI artifacts
windows-pytest-results/
+652
View File
@@ -9,6 +9,658 @@ PyPI version — not the changelog header.
---
## [2026-07-11]
### Added
- **Owner seating made permanent + self-healing for every project (DPLAN-0239,
fixes #693).** The owner-capability guard was correct but the DATA was never
seeded: every project created before 2026-07-10 had its owner only in the
self-editable passport, never in the sealed registry (8/8 external projects
unseated; AIPass's own registry was missing `metadata.id` with 13 entries
sharing one stale id). Identity model settled: registry `metadata.id` =
project credential (passports conform); branch-entry `registry_id` =
set-once PER-CITIZEN UUID minted at entry creation; entry `owner:true` =
the authority gate (first agent), chosen by ONE shared heuristic
(`pick_owner_branch`: manager → passport owner → first-created).
New: `drone @spawn sync-registry --check [--json]` (read-only, 7 health
flags, pinned JSON schema) and `--fix [--dry-run]` (idempotent reconcile:
seat owner, majority-consensus restore of `metadata.id`, mint citizen UIDs,
align passports; dry-run fully read-only; never moves a seated owner).
`aipass doctor` renders owner health per flag; `doctor --fix`, `install`,
and `init update` delegate repair to spawn — existing/external projects
self-heal on next update (the missing DPLAN-0231 PART-4 trigger). The adopt
path now seats owners; `placeholders.py` resolves the registry from the
target dir (was CWD) and fails loud. @hooks `auto_watchdog` now injects the
real Monitor-tool watchdog command with the actual @target (was a dead
one-liner + `run_in_background`, which cannot wake a session). Deployed
live: AIPass + 6 external projects reconciled and verified clean — VERA is
now seated owner of Vera Studio (`is_owner('@vera') = True`, was refused).
Owners built (spawn 343 / aipass 673 / hooks 961 tests green); devpulse
verified every diff, live-ran every stage, full-repo sweep 9364 passed
(1 pre-existing skills litter fail → #694).
### Changed
- **Fleet seedgo compliance sweep — every branch to 100% (issues #686, #661).**
Overnight campaign bringing all branches to 100% on the seedgo standard pack.
#686 (Subcommand_Help, per the #685 contract): entry points intercept
`<cmd> --help` before dispatch, so `--help` shows help instead of executing.
#661 (Output_Routing): status/error console output routed through the shared
`@cli` `success()/error()/warning()` helpers instead of raw `console.print`
markup. Owners self-audited and self-fixed their own branches; devpulse verified
each diff + re-ran each audit and committed per wave. Landed so far: spawn,
drone, flow, daemon, prax, ai_mail, backup, seedgo, memory, trigger, api, cli,
aipass, commons — all 14 offenders now at 100%. **Fleet: 17/17 branches at
100% seedgo compliance** (hooks, skills, devpulse were already compliant).
Owners self-audited and self-fixed; devpulse verified every diff, re-ran each
branch's full test suite, and committed per wave. A full 17-branch test run
(~10,349 tests) surfaced one pre-existing flaky test in drone
(`test_pr_no_branch_dir` / `test_pr_no_args` lacked cwd isolation, so a real
checkout's findable passport made the auth path pass unexpectedly) — given
`monkeypatch.chdir(tmp_path)` isolation to match its sibling test, so the full
suite is now deterministically green.
### Fixed
- **Watchdog Monitor wake no longer double-fires (#693 follow-on, reported by
VERA via the feedback channel).** The `watchdog agent` reminder banner
("invoke via Monitor tool, not run_in_background") printed to STDOUT at arm
time, and the harness Monitor tool treats every stdout line as a wake event —
so every armed watchdog fired a spurious wake the instant it armed, then the
real wake at completion. Rerouted to stderr (`err_console`); stdout now
carries completion/stall events only, matching the contract the agent handler
already followed. Verified live: exactly one wake, on real exit. The devpulse
README watchdog/feedback sections were also rewritten to document the owner
gate, the 3-step Monitor wake mechanic, why no passive wake can exist, and
the 600 s default timeout.
- **Watchdog agent tests thread-race flakes made deterministic (devpulse).**
Four tests patched the GLOBAL `time.sleep` with stateful/side-effecting
fakes; prax's logger spawns daemon threads on first log, which executed the
fakes concurrently with the test (advancing a fake clock, unlinking the
fixture lock early, or re-truncating `last_bounce.json` mid-read in
`_classify_exit` → `exit_code=None`). All fakes are now thread-scoped via a
caller-frame guard: only sleeps from the agent module trigger the test's
side effect; foreign threads get a real 1 ms sleep.
- **seedgo-audit back to 100 % after the S300 commits (PR659).** Two 99 %
regressions from that day's own work: `aipass` `doctor.py` `_fix_owner_seating`
had two silent catches (now log via prax like the sibling check function),
and the devpulse README claimed 407 tests where the readme checker counts
test functions (corrected to 309).
- **Two more non-hermetic ai_mail tests made deterministic (PR659).** With the full
suite now running on varied CI runners, `test_get_pid_cwd_darwin_failure` and
`test_is_zombie_linux_no_proc` intermittently failed: they called the real `lsof`
(via `subprocess.run`) and real `open("/proc/…")` for a fixed PID (999 / 99999),
so on a runner where that PID happened to exist they returned a non-`None` result
instead of the expected failure. Mocked `subprocess.run` and `builtins.open` so the
tests assert the failure contract without touching real process/`/proc` state.
Test-only; deterministic across repeated runs.
- **Windows CI cross-platform fixes — `windows-setup` green (PR659).** Fixing the
telegram collection errors unmasked 14 pre-existing Windows-only failures across
six branches. Two root causes. **(1) pid-liveness tests** (ai_mail, flow, hooks,
skills) mocked `os.kill`, but the production `_is_pid_alive` already branches to a
ctypes `OpenProcess` path on Windows and never reaches `os.kill`, so the mocks had
no effect and the real path ran instead — pinned `sys.platform` to `linux` in those
tests (or patched `_is_pid_alive` directly) so they exercise the POSIX contract
deterministically on every platform. **(2) POSIX path assumptions** — prax's jsonl
test hardcoded `/some/path` (backslashes under `str(Path)` on Windows) now asserts
against `str(test_path)`; hooks' rollover test compares `repr()` (matches `%r`
logging); ai_mail's darwin lsof-parser test uses a fixed POSIX path; and seedgo's
`is_bypassed()` now normalizes the rule file via `Path(rule_file).as_posix()` before
matching (the one production fix — Windows backslash rule paths never matched the
forward-slash file path). 10 files (9 test, 1 code); owners self-fixed, devpulse
verified every diff + Linux no-regression (525 changed-test assertions green).
- **Flaky `test_deletes_old_system_log` made deterministic (@prax log-sweep tests).**
The sweep integration test reached `log_watchdog._get_system_logs_dir` through a
`_get_sweep()` wrapper and patched it by string path; a sibling test
(`test_logging_handlers.py`) `sys.modules.pop`s and reimports `log_watchdog`,
creating a second module object — so the string patch could target a different
object than the function's `__globals__`, the sweep scanned the real (empty)
`system_logs/`, removed 0 files, and `assert files_removed == 1` failed
intermittently (the same commit passed in one CI run and failed in another).
Switched to a direct `import log_watchdog as lw` + `patch.object(lw, …)` (shared
module `__dict__`) and patched `json_handler` to block real file I/O. Test-only
(1 file); prax suite 978 green, two full-repo runs 11,019 passed each, sweep tests
deterministic across repeated runs.
- **Telegram skill tests made CI-safe — full-repo collection + hermeticity (issue #691).**
The 16 test files under `skills/lib/telegram/tests/` imported handlers via bare
`from apps.handlers…`, which collided with other branches' `apps` packages during
full-repo CI collection (~16 ImportError collection errors → CI red on Linux +
Windows). Converted to fully-qualified `aipass.skills.lib.telegram.apps.handlers.*`
imports (and matching `mock.patch` targets). Verifying that fix surfaced a second
problem the imports had exposed: ~11 tests reached the live Telegram API
(`base_bot.run → _set_command_menu → set_bot_commands → urlopen`) — they had never
run in CI before because they failed at collection. Added a session-scoped autouse
`_block_network` conftest fixture that patches `urlopen` on the four network-using
telegram modules (both bare and fully-qualified import paths, each guarded) so any
test attempting a live HTTP call fails loud instead of hanging. A full-repo CI run
then exposed a third layer the isolated suites had hidden: `handler.py` and its
routing tests still used bare `from apps.handlers.X import Y` / `mock.patch("apps.
handlers.X…")`, which resolve to the *wrong* branch's `apps` in a whole-repo run
(AttributeError / ModuleNotFoundError at runtime — 17 `test_handler_routing`
failures). Fully-qualified those to `aipass.skills.lib.telegram.apps.handlers.*` in
both `handler.py` (7 lazy imports, now house-rule compliant) and the tests; the
skill's runtime behaviour is unchanged (verified via `drone @skills run telegram`).
Net: full-repo collection 0 errors and the whole 11k-test suite green; telegram
suite 663 passed / 0 failed / 0 hangs, fully hermetic; coverage intact (import and
patch-target rewiring only — zero assertion changes).
- **`aipass install` from a throwaway path can no longer hijack the machine-wide
`AIPASS_HOME` (issue #688).** A probe install run from a `/tmp` scratchpad had
rewritten `~/.claude/settings.json` `env.AIPASS_HOME`, silently pointing every
Claude Code session on the machine at a dead temp tree (stale python, stale
hooks — surfaced as bogus ImportErrors in unrelated work). Three defenses:
`bootstrap.is_throwaway_path()` gates the settings write itself (temp dirs +
scratchpads never land in global settings); `run_install` refuses a throwaway
home loudly with `--force-global-home` as the explicit override; and
`aipass doctor` gains a `global AIPASS_HOME` check that flags a nonexistent or
throwaway path with fix guidance. +11 tests. Ships with a probe-hygiene SOP
(`aipass/docs/probe_hygiene.md`): temp installs are used, deleted, gone — nothing
permanent may point at a temp path. Tests made location-independent so the suite
is green from any cwd and from a `/tmp` clean-room extraction, not just the repo
root. (built by @aipass, verified + test-hardened by devpulse against the real
hijack path)
## [2026-07-10]
### Added
- **Owner-capability model — project ownership sealed in the registry, and the
owner is woken back when a dispatched agent completes (issue #678).** The
directed-wake round-trip grew into an access-control primitive: watchdog /
feedback / wake-back are owner-only privileges, and the owner (first agent /
`citizen_class: manager` — devpulse in AIPass) is resolved from the *sealed*
`*_REGISTRY.json`, not the self-editable passport (no self-grant). Three parts
built in parallel against a frozen `is_owner` contract: `@spawn` writes
`owner` + `registry_id` into registry entries and exposes `get_owner()` /
`is_owner()` (`ensure_project_has_owner` now keys off the manager signal, not
the created-date heuristic that mislabeled `@aipass`); `@hooks` adds a
`registry_gate` PreToolUse handler that blocks raw writes/edits/deletes of
`*_REGISTRY.json` and redirects to `drone @spawn` (per-clause bypass defeats
compound-command smuggling; reads stay allowed); `@ai_mail` reslopes the
dispatch wake-back from a `SKIP_SENDERS` blocklist to an `is_owner` allowlist.
Cross-part verified end-to-end by devpulse with the real resolver (gate 13/13
incl. compound-smuggle, wake-back owner/non-owner/depth-cap).
(built by @spawn + @hooks + @ai_mail, verified by devpulse)
- **`subcommand_help` seedgo standard — entry points must intercept `<cmd> --help`
before dispatch (issue #685, split from #665 item 3).** `drone @X <cmd> --help`
had no framework contract: drone (a router, not a standards enforcer) forwards
`--help` as a positional, so behavior was per-branch — 8/16 missed, and two
branches *executed* the subcommand instead of showing help. seedgo now owns the
contract: a new AST checker flags entry points that don't guard `<cmd> --help`
(explicit `remaining_args[0]` guard or argparse `parse_known_args`). 21 tests,
cwd-portable. 7/17 branches comply; the 10 offenders are tracked as a fleet
migration (#686). (@seedgo, verified devpulse)
- **`windows_compat` now detects `os.kill(pid, 0)` liveness probes, not just
documents them (issue #682).** `os.kill(pid, 0)` resolves to `TerminateProcess`
on Windows — it *kills* the target instead of probing it. The checker documented
the anti-pattern but never flagged it in source. A new detector recognizes the
valid early-return platform guard (so the reference impl `watchdog/agent.py` isn't
false-flagged) while catching genuinely unguarded sites. 6 tests; verified across
the fleet (guarded ref passes, 10 offenders caught → fleet migration #684).
(@seedgo, verified devpulse)
- **`append_jsonl` — a sanctioned rotating JSONL writer + a 30-day stale-log sweep
(issue #673).** Branches wrote `.jsonl` via raw `open('a')`, bypassing prax
rotation (which was `.log`-only) — unbounded log growth. `from aipass.prax import
append_jsonl` gives 500 KB / 1-backup atomic (`os.replace`) rotation with zero
dependency on the prax logging pipeline (recursion-safe for @trigger's event
handlers), and `drone @prax log-audit sweep` deletes logs older than 30 days
across system + branch logs. The raw appenders in @backup (1), @hooks (2), and
@trigger (11 `.log` sites → `.jsonl`, plus downstream medic readers) all adopted
it — zero raw log appenders remain fleet-wide.
(@prax + @backup/@hooks/@trigger, verified devpulse)
- **Hook engine: Codex bridge + portable test suite (issue #635, DPLAN-0184
leftovers).** The engine now drives Codex hooks the same way it drives Claude:
new `handlers/bridges/codex.py` mirrors the claude.py bridge (same
`EventType:hook_name` dispatch) with Codex protocol normalization — stdin
remaps `input`→`tool_input`, stdout wraps in the `hookSpecificOutput` envelope
(`additionalContext` for injection, `permissionDecision` +
`permissionDecisionReason` for blocks — fixing the known DPLAN-0205 bugs:
missing reason, wrong field name). And `drone @hooks test` is a portable
drop-in runner that fires every hook from `.aipass/hooks.json` with mock data
per event type and reports fired/blocked/disabled/crashed with timing
(`--verbose` previews output). 23 new tests (12 bridge + 11 runner), seedgo
31/31 both. (built by @hooks, verified by devpulse)
### Fixed
- **hooks/bridge: `-p` headless invocations no longer routed through tmux
(issue #677, DPLAN-0226 fine-tune leftover).** The boot wrapper
(`session_boot.py`) applied its tmux/session-lookup/live-attach logic to every
invocation — wrong for `claude -p`, a non-interactive one-shot that never
registers in `~/.claude/sessions`. The wrapper now detects `-p` in extra_args
and short-circuits to direct `execvp` of claude — no tmux, no session lookup.
+5 tests (39 pass). (built by @hooks, verified by devpulse)
- **Owner-capability PART 4 — devpulse's `watchdog` + `feedback` now gate on the
sealed-registry owner, and cross-project (issue #681).** Closes the
owner-capability model (#678): the last two owner-only tools were still gated
by a hardcoded `cwd.name == "devpulse"` check — which, it turns out, was a
**no-op through drone**: drone runs a routed module with `cwd=<branch_path>`,
so the module's own `Path.cwd()` is *always* the devpulse tree and can't
identify the caller (a `@flow` caller sailed straight through). A new shared
`handlers/owner/guard.py` resolves the *real* caller from the env drone sets
(`AIPASS_CALLER_BRANCH` / `AIPASS_CALLER_CWD`) and checks it against the sealed
owner via the frozen `is_owner(email, start_path)` contract — so it works in
any project (devpulse in AIPass, whoever owns elsewhere), not a hardcoded name.
`feedback send` stays open (it's the inbound channel any agent uses to drop
feedback to the owner); every mailbox read/manage verb is owner-only. Fail-safe:
if no owner is sealed yet (old/partial install) or the resolver can't import,
it falls back to the legacy devpulse-path heuristic so existing installs never
hard-break. Live-verified end-to-end: owner allowed, `@flow` denied on both
tools, `send` open. 18 new tests (15 guard + 3 gate), branch audit 100%.
(built + verified by devpulse)
- **seedgo `json_structure` now sanctions `custom_config/` for operator-editable
config (issue #643).** The standard said "`{branch}_json/` root, one directory,
no splits" and the checker ignored subdirs, so `custom_config/` (home of
operator-tunable runtime config like `cadence_config.json`, `memory.config.json`)
was an undocumented convention. `json_structure_check.py` gained an
`ALLOWED_JSON_SUBDIRS` allowlist and a `check_branch_post()` that validates
`{branch}_json/` subdirs — `custom_config/` and hidden dirs (`.archive`) pass,
any other split is flagged. `json_structure_content.py` documents the directory
structure and operator-config location. The subdir check honors
`.seedgo/bypass.json` (bypass rules are threaded through
`check_branch_post` → `_check_json_dir_structure`), so a branch can sanction a
legitimate data subdir while unsanctioned + unbypassed splits still fail. 7 new
tests. (The new check surfaced `devpulse_json/compass/` — the devpulse Compass
SQLite/FTS5 decision store, which needs its own directory — now sanctioned via a
documented devpulse bypass; audit confirms Json_Structure back to 100%.)
- **`git_gate` block messages now guide external users instead of dead-ending
(issue #620).** A blocked raw `git`/`gh` command previously just errored. The
block message now explains *why* git is enforced (prevents cross-agent state
conflicts), lists the key `drone @git` commands (commit, smart-sync, sync, pr,
checkout), points to `drone @git --help`, and shows how to disable the gate in
isolation (`git_gate.enabled = false` in `.aipass/hooks.json`) — verified
against the engine, which skips a disabled hook per-hook without affecting other
hooks or `drone @git`. The combined `GIT_GH_REDIRECT` was split into distinct
`GIT_REDIRECT` + `GH_REDIRECT`; `EDIT_REDIRECT` also shows the disable path. An
init notice was added to the `project_hooks.json` template and the on/off story
documented in the hooks README. 6 new tests (86 in `test_git_gate`).
- **Telegram `/create` + `/cancel` are now gated to the base @aipass bot (issue
#644).** Every per-branch bot inherited `BaseBot`'s `/create` + `/cancel` and
could mint new bots — but Patrick designated the base @aipass bot as the *sole*
spawner. `base_bot.py` now guards on bot identity (branch bots carry a
`branch_name`; the base bot's is `None`): `_dispatch_command` returns `False` for
`create`/`cancel` on a branch bot (falls through to normal handling), and
`get_custom_commands` advertises them only for the base bot. Rode along in the
same @skills pass: fail-loud fixes to `botfather_client.py` (issues #669.2/#669.3,
already closed) — `_load_telethon_config` now raises `RuntimeError` naming the
config path and the `drone @api set-secret telegram telethon_config` command
instead of silently returning `None` — plus poll-offset test coverage (#668).
133 telegram tests pass, seedgo 31/31 on both source files.
- **seedgo no longer lints throwaway code (issue #675).** A single disposable POC
used to fire 8 standard violations (architecture, meta, shebang…). The audit and
checklist now skip any file resolved under a system temp dir
(`tempfile.gettempdir()` / `/tmp`, cross-platform) or a `scratchpad` path, and a
new `--prototype` flag (plus an in-file `# seedgo: prototype` marker in the first
5 lines) exempts disposable code explicitly. Wired into
`branch_audit._collect_py_files` (throwaway filter) and `checklist.run_checklist`
(early-return skip). 6 new tests; live-verified that a `/tmp` file and a
marker-tagged file both report "✓ (skip)".
- **The `claude()` boot shim now ships and installs on onboarding (issue #666).**
Its installer (`hooks/tools/install_boot_shim.sh`) lived under a gitignored
`tools/` dir — never version-controlled, never shipped — so the
attach-if-live / start-in-tmux boot feature (and presence-gate-via-boot) was
dev-local only; a macOS user could not attach/resume their session. A root
`.gitignore` negation now tracks exactly that one file (`tools/` re-ignored,
only the installer whitelisted — README stays out), and `setup.sh` runs it
right after hook installation (idempotent via a marker check, non-fatal on
error, venv Python resolved from the script's own location for POSIX/Windows).
Fresh clones and `aipass install` now get the shim.
- **Interactive-occupancy detection is now cross-platform — the wake-back guard
no longer goes blind on macOS (issue #680).** `_is_branch_occupied()` and
`_read_session_type()` (duplicated in `dispatch/wake.py` and `dispatch/daemon.py`)
read `/proc/{pid}/cwd` + `/proc/{pid}/environ`, which do not exist on macOS —
so occupancy always resolved `False` there and an external wake-back could spawn
a *second* Claude session on an already-interactive branch (double-session,
weakening the TDPLAN-0012/#678 interactive-dispatcher guard). The per-PID cwd
and session-type probes are now extracted into platform helpers: `_get_pid_cwd`
(Linux `/proc` readlink, macOS `lsof -a -p PID -d cwd -Fn`) and
`_read_session_type_darwin` (`ps -p PID -wwE`), applied identically in both
files. Fail-safe: an unreadable cwd/env logs at info and continues — never
crashes the wake path. +11 tests (macOS cwd, macOS session type, zombie,
unsupported platform), seedgo 31/31 on both files.
- **SubagentStop gate no longer runs its ~600ms seedgo check on every internal
turn (issue #606).** Claude Code creates an internal agent per response turn
with an empty `agent_type`, so the `subagent_gate` handler was firing its full
`drone @git status` + seedgo modified-files check on every turn, not just when a
real Agent-tool sub-agent completed. `handle()` now early-returns `_ALLOW` when
`agent_type` is empty; the full check runs only for a real sub-agent
(non-empty `agent_type`). Piper speech is a separate notification hook and is
unaffected — the trust layer stays visible. 3 new tests (empty skip, missing-key
skip, real-agent full check), 17/17 green.
- **Watchdog stall detector no longer false-fires on a long single tool call, and
a real stall now reaches devpulse live (issue #634).** Liveness was inferred
purely from JSONL file-size growth, so an agent doing one genuinely long
operation (big read, long-running Bash, heavy compute) wrote no new lines for
the span and was misread as `STALLED` while actively working. `watch_agent` now
also treats an in-flight `tool_use` (the assistant's last transcript entry while
a tool runs) as activity — verified live against real Claude Code transcripts:
the `tool_use` line is written at tool *start* and persists for the whole call.
Part 2: the stall (and a new long-running-tool advisory, plus a resumed signal)
is emitted to **stdout** — which the Monitor-tool wrapper surfaces as a live
event — instead of only `stderr`+logger, which Monitor captures but never
relays. Stall logic extracted into a `StallTracker` for clarity; +9 tests
(142 green), devpulse audit 100%. (devpulse)
- **`aipass install` shows progress during the slow dependency build, and a README
quick-start command is corrected (issue #665, items 6–7).** The editable install of
the `[memory]` extras ran with `pip --quiet`, going silent for minutes during wheel
builds — it looked hung; dropped `--quiet` on that step and set expectation in the
echo. And `README.md` showed `drone @seedgo audit my_project`, which fails
(`audit` takes a registered pack name) — corrected to `audit aipass`. Remaining
#665 items (version, --help names, subcommand --help, placeholders, hints, crash-vs-
unknown) span multiple owners and stay open. (devpulse)
- **`aipass`/`drone` first-contact papercuts resolved — issue #665 fully closed
(items 1, 2, 4, 5, 8).** `aipass --version` now reads package metadata (was
hardcoded `0.1.0`); `aipass --help` lists real `COMMAND` names, not file stems
(`help` not `help_chat`, `init` not `init_flow`); a crashing or unimportable
handler surfaces its real cause instead of `Unknown command` (@aipass). `drone
systems` placeholder descriptions now derive from each branch's passport/README,
fixed in code so they survive registry regen — the earlier gitignored data edit
didn't (@spawn). Bare-mode hints point to working commands — `drone @daemon
--help` (there is no `daemon` binary) and the standard `drone @memory --help`
(@daemon, @memory). Item 3 became the #685 standard. (multi-branch, verified devpulse)
- **`os.kill(pid, 0)` liveness probes across the fleet are now Windows-safe (issue
#684).** On Windows `os.kill(pid, 0)` maps to `TerminateProcess` — the "probe"
kills the target. Nine sites across @ai_mail (dispatch daemon/wake), @drone (git
lock handler), @flow (runner lock), @hooks (cc_sessions/presence) and @devpulse
(watchdog registry) now early-return to an `OpenProcess` + `GetExitCodeProcess`
check on win32, mirroring the `watchdog/agent.py` reference. The #682 checker
confirms 0 unguarded sites remain (down from 10); the last one,
`tools/git_lock_tool.py`, is split to #687 (blocked by the tool's pre-existing
gate debt). (fleet migration, verified devpulse)
- **Telegram poll loop no longer re-drains a rate-limited backlog; systemd
suicide-loop + silent config fallback fixed (issues #668, #669).** #668: the poll
loop advanced the update offset *after* processing, so a rate-limited/erroring
update never advanced it — the same backlog re-fetched in a flood loop. The
offset now advances *before* `process_update`, so a consumed update never pins
it. #669: (1) systemd unit gets `KillMode=process` so a restart isn't killed by
the old instance's cgroup teardown (suicide-loop); (2) `create_bot_via_botfather`
now **raises** with an actionable message (naming the `set-secret` fix) instead of
silently returning `None` when telethon config is missing (fail-honestly);
(3) stale config-mechanism docstrings corrected. Also Windows-hardened
`_is_pid_alive`/`_check_lock` and switched `TEMP_DIR` to `tempfile.gettempdir()`.
653 telegram tests green. (@skills, verified devpulse)
- **Rollover `_find_repo_root` now fails loud, and `edit_gate` warns on over-count
memory sections (issue #683, #664 follow-up).** The PreCompact rollover hook's
`_find_repo_root` returned `None` silently when `AIPASS_HOME`/cwd was wrong — the
exact silent-skip that hid #664 for months; it now logs a `logger.error` with the
`AIPASS_HOME` value and cwd before returning. And `edit_gate` enforced per-entry
*character* caps but not entry *counts*, so a branch could drift past its count
cap between rollovers; a soft `_check_section_counts` now warns (never blocks),
reading the same `memory.config.json` rollover caps @memory uses. +14 tests
(70 green); both live-proven (bad root → error logged; over-cap → warn, no block).
(@hooks, verified devpulse)
- **`is_owner()` now case-folds — `is_owner('DEVPULSE')` matches `is_owner('devpulse')`
(issue #679).** The spawn-registry resolver (`registry.py:382`) `@`-normalized the
email but never lowercased, so a mixed-case branch name (registry names are
mixed-case: `DEVPULSE` vs `devpulse`) returned `False` against the seated owner.
Harmless today (the only caller lowercases first) but the frozen TDPLAN-0012
contract promises normalization, and PART-4 owner-gating may pass a raw name.
Now lowercases both sides; verified live (every case variant of the owner → True,
non-owners → False) + a case-insensitivity test (316 green). (@spawn, verified devpulse)
- **`aipass install` no longer hard-fails (exit 2, silently) when it can't create
global symlinks (issue #660 follow-up).** `setup.sh` runs under
`set -euo pipefail`; the #660 `safe_symlink` refactor returns `2` on `ln`
failure, but the call sites captured that code on the *next* line (`rc=$?`), so
`set -e` killed the installer at the symlink step — before the `~/.local/bin`
fallback (built for exactly the no-sudo case) could run. Any sudo-less
environment (containers, CI, locked-down machines) got a silent exit 2 with no
symlinks, despite an otherwise-complete install. Fixed all three call sites to
`rc=0; safe_symlink … || rc=$?` (set-e-safe). Proven in docker: a sudo-less
install now falls back to `~/.local/bin` and exits 0. (devpulse)
- **`drone @devpulse watchdog agent` no longer reports failure on a successful
watch (issue #661).** Its "invoke via Monitor tool" reminder was printed
through `cli.error()`, which — after the #661 exit-code work — trips a
process failure flag, so every successful watch exited non-zero with a red X.
Rerouted to a dim console note; genuine argument errors still `error()` →
exit 2. (devpulse)
- **The prax monitor now holds a single-instance lock, so a duplicate/orphan
monitor can't double-send Telegram relay messages (issue #671).** A new
`instance_lock` handler writes a pidfile (`prax_json/monitor.pid`, outside the
tailed `system_logs/`) with a liveness check: `acquire()` runs before relay
init and refuses to start (fail-loud, naming the holding PID) if a live monitor
already holds the lock, reclaims a stale pidfile when the recorded PID is dead,
and `release()` clears it on shutdown. The liveness probe is platform-branched
— POSIX `os.kill(pid, 0)`, Windows `OpenProcess`/`GetExitCodeProcess` (a raw
`os.kill(pid, 0)` *terminates* the target on Windows). `monitor.py` was also
split under the 600-line limit (`pid_cache` extracted). +25 tests.
(built by @prax, verified by devpulse)
- **`aipass init update` now refreshes `AGENTS.md` and prunes stale managed
cruft (issue #676).** Two gaps: (1) `update_project` synced `AGENTS.md` from a
`.aipass/project_AGENTS.md` template that never existed, so the branch silently
no-op'd and `AGENTS.md` was never refreshed on update (only `CLAUDE.md`, whose
template exists, synced) — added the template and reconciled create/update to
one source; (2) the update was additive-only — added a whitelist-scoped cleanup
pass (`_STALE_MANAGED_FILES`, currently the retired `aipass_global_prompt.md`)
that removes only positively-identified managed artifacts, logs every removal,
and never touches user-owned files. The template also had to be un-ignored in
`.aipass/.gitignore` (allowlist) or it would never have shipped — caught in
verify. +7 tests; live repro confirms update emits `AGENTS.md` and clears a
planted cruft file. (built by @aipass, verified by devpulse — incl. the
gitignore ship-gap)
- **External-project branches now auto-roll — rollover discovery is no longer
cwd-scoped (issue #664).** Branch discovery only saw registries reachable by
walking up from the caller's cwd, so branches living solely in an external
project's `*_REGISTRY.json` were never reached by rollovers fired from the
AIPass tree (the PreCompact hook runs with cwd = repo root) — their `.trinity`
files grew unbounded (one hit 110 key_learnings against a 15 cap) and vector
stores went stale. `@memory` added a persisted `known_registries.json`
(gitignored per-install data) that records every external registry seen via the
cwd walk, so discovery reaches them regardless of caller cwd; stale/deleted
registry paths are filtered on load. Plus a soft entry-**count** guard at write
time (warns, never blocks) since the write gates only enforced char caps. The
remaining hooks-side harden (`_find_repo_root` fail-loud + the `edit_gate`
count-guard) is filed for `@hooks`. +12 tests; live repro confirms a rollover
fired from the AIPass root now reaches an external-registry branch.
(built by @memory, verified by devpulse)
---
## [2026-07-09]
### Added
- **Exit-code contract foundation — failing commands can now exit non-zero
(issue #661, in progress).** CLI error paths printed an error but returned exit
`0`, so `$?`-checking callers (core to running `drone` as a subprocess) were
told success on failure. The dispatch contract was a 2-state bool (`handled` /
`not-mine`) with no way to say "handled *and* failed". `@cli` now exposes a
process-level failure flag + `resolve_exit(handled)` (→ `0`/`1`/`2`), and
`error()` auto-trips the flag — so any failure routed through `error()` gets a
correct non-zero exit with zero per-site edits, and it can't regress. Inert
until a branch's `main()` adopts it. `@seedgo` added an `output_routing`
standard (39th checker) flagging user-facing status output that bypasses the
cli helpers — 254 sites across 14 branches, the migration checklist. `devpulse`
is the first adopter (`main()`→`resolve_exit`, feedback migrated to `error()`,
exit `2`/`0`/`1` verified, 100% seedgo). Fleet migration to follow.
(built by @cli + @seedgo)
### Fixed
- **`@trigger` no longer rewrites its 44KB `trigger_data.json` on every log event
(issue #674).** The branch log watcher persisted dedup hashes and log positions
with two separate full-file rewrites *per event*, so a log burst churned the
file ~1-2×/sec (surfaced by prax monitoring). Replaced the per-event/counter
writes with a debounced coalescing writer: events set a dirty flag and both
keys are written in a single atomic write at most once per 5s, with a forced
flush on watcher stop so nothing is lost on clean shutdown. Also confirmed the
retired `bulletin_created` event handler no longer loads or warns (it lives in
`.archive/` with no live references; scrubbed stale README/bypass mentions).
564 trigger tests green (+6 debounce tests).
- **`aipass install` no longer silently repoints your global `drone`/`aipass`
symlinks (issue #660).** `setup.sh` force-overwrote the global CLI symlinks with
`ln -sf` on every run, no check and no opt-out — so `aipass install
--path /tmp/scratch` "to try it" silently hijacked your real global commands to
the scratch tree, which broke them once `/tmp` cleared, disconnected from the
cause. A new `safe_symlink` guard refuses to repoint a symlink that points at a
*different* install: it prints a loud from→to warning and leaves the existing
link untouched unless you pass `--force-symlink`; `--no-symlink` opts out of
symlinking entirely. Both flags thread through `aipass install`. Fresh installs
and same-location reinstalls behave exactly as before. Adds a `safe_symlink`
regression test (`tests/setup_symlink_guard_test.sh`) and 3 flag-forwarding
tests; the touched install output was migrated to `@cli` helpers (#661).
- **`drone @flow close` no longer reports a false "timed out after 30s" on a
successful close (issue #662).** A single-plan close committed early (plan
marked closed, file archived) and then ran memory vectorization
*synchronously* — `drone @memory process-plans` — inline. On the cold first
close of a session that crossed drone's 30s executor timeout, so drone killed
the flow subprocess and returned exit `1` **after** the close had fully
committed. An autonomous agent reading that exit code would retry or abandon an
already-closed plan. `close_plan_impl` now honors its long-existing
`spawn_background` flag: single close fires the already-detached
`_spawn_background_runner` (the same path `close_all` uses) and returns
immediately after archive; vectorization runs in the background. Also removed
the handler's cross-handler imports (archive/trigger now injected). Verified
live: a real close returns in ~5s at exit 0 ("Vectorizing in background") vs
the prior 30s-timeout risk. 730 flow tests green (+2 new).
- **`aipass doctor` no longer hangs on non-interactive stdin (issue #663).** The
auto-wire `[y/N]` prompt called `input()` with no tty guard, so a caller with a
blocking-but-idle stdin (a script, CI job, or subprocess whose stdin never
sends EOF) hung `doctor` indefinitely — reading as a crash from the flagship
"check my system" command a new user runs first. `prompt_auto_wire` now guards
the prompt with `sys.stdin.isatty()`: a non-tty stdin declines the auto-wire
(prints the manual-wire warning) instead of blocking. Verified against the
exact repro — a blocking non-tty stdin that never EOFs now completes instead of
hanging until killed. Adds 3 regression tests.
- **macOS session lock-out: the boot wrapper can now see tmux sessions on
macOS.** `session_boot` decided whether a live Claude session lived inside
tmux by walking the process tree through `/proc/<pid>/status` — Linux-only.
On macOS (no `/proc`) that walk always failed, so the wrapper concluded every
live session was "outside tmux" and refused to attach, locking the user out of
their own session in an unbreakable loop. Replaced the `/proc` read with a
portable `ps -o ppid=` ancestry walk (Linux + macOS). Also: both the boot
warning and the presence-gate block now spell out the exact recovery command
(`kill <pid> && claude`, `command claude --resume`) instead of a vague "kill it
first", and the wrapper no longer doubles `--permission-mode` when the user
passes it explicitly. New/updated tests, hooks suite 791 green. (built by @hooks)
- **Boot-shim installer no longer bakes a hardcoded user path.**
`install_boot_shim.sh` hardcoded `/home/patrick/Projects/AIPass/.venv/bin/python`
into the `claude()` shell function — wrong on any other machine or user. It now
resolves the venv interpreter from the script's own location (POSIX
`.venv/bin/python`, Windows/git-bash `.venv/Scripts/python.exe`, else PATH
`python3`) and bakes the correct one at install time.
- **Silent hook-wiring break: provider settings could be left half-wired with no
warning.** A stale `setup.sh` merge orphaned the `SessionStart` hook event to an
empty `[]` — the key existed but nothing fired — written silently, and it went
unnoticed for weeks because CI skips the provider-settings snapshot test (it
needs `~/.claude/settings.json`, absent in CI). Root cause: the merge stripped
every AIPass bridge entry per event, then re-added only events still present in
its own hook list, orphaning any event it no longer defined. The merge now drops
such an event entirely (and says so) instead of emitting an empty array. Also
corrected the stale snapshot fixture (dropped the dormant `presence_gate`, which
by design ships wired only in project config, and added
`SessionStart:cadence_reset`) and marked `presence_gate` `provider_wired: false`
so the wiring checker knows it is intentionally not provider-wired.
- **`json_handler.load_json` crashed on an empty/whitespace file (#667).** Under
concurrent audit + tests a writer could truncate a JSON file in the window
between `ensure_json_exists` and `load_json`'s own read, raising
`JSONDecodeError`. `load_json` now guards an empty/whitespace read and falls back
to the type's default template; a non-empty but malformed file still raises (fail
honestly). 3 new tests, red-green proven.
### Added
- **`drone @hooks verify` — hook-wiring integrity checker.** Cross-checks
`~/.claude/settings.json` against `.aipass/hooks.json` and fails loud on empty
provider hook arrays, orphaned entries, enabled handlers with no provider bridge,
and duplicate (matcher-aware) entries — so a half-wired hook can never rot
silently again. `aipass doctor` now runs this check under Services and re-verifies
after `--fix`. 40+ new tests. (built by @hooks + @aipass)
## [2026-07-07]
### Fixed
- **Drive sync now respects `.backupignore` on the sync path.** The ignore spec
was applied at backup time only — anything already inside `.backup/versioned/`
got uploaded regardless. Real case: Vera-Studio's store carried 37K legacy
`node_modules` files (92% of the store), turning a KB-sized sync into a 7-8
hour crawl (Drive uploads are per-file API round-trips — latency-bound, not
bandwidth-bound; the clean store syncs in ~13 min). `drive_sync` now re-filters
store files through the project's `.backupignore` before upload and logs the
ignored count. Also fixed: `json_handler.log_operation` crashed on `Path`
objects (`PosixPath is not JSON serializable`) — now serializes with
`default=str`. 2 new tests, backup suite 247 green. (built by @backup)
### Added
- **Fresh-context grounding: cadence reset on new chat / clear / compact.**
Both prompt loaders (tier0 kernel + navmap) now run at period 5, and a new
`SessionStart` hook resets the cadence counter on `startup`/`clear` (skips
`resume` — restored context already carries grounding; `compact` was already
reset via PreCompact). Net effect: the first message of every fresh context
gets full grounding, then every 5th turn after. Wired end-to-end: handler
(`session_start.py`), project config (`.aipass/hooks.json` + the
`project_hooks.json` template for external projects), and `setup.sh` seeds
the provider `SessionStart` entry for new installs. Proven end-to-end from a
real fresh-user clone of dev in Docker — 19/19 assertions via the new
`tests/docker_dev_verify.sh` (bridge-era; supersedes the stale
`docker_clone_test.sh`). (built by @hooks + @devpulse)
### Fixed
- **`aipass` ≠ drone-routed — misroutes now guide instead of crash.** `aipass`
is the user's front-door CLI, deliberately not resolvable by drone. But
`drone aipass` misdirected, `drone @aipass` crashed with a traceback, and
`aipass @drone` dead-ended. All three now print clear guidance (what aipass
is, what drone is, how to reach each). Kernel + navmap prompts updated so
agents know the exception. (built by @drone + @aipass)
---
## [2026-07-06]
### Fixed
- **prax log watchdog now covers branch `logs/` dirs — `.jsonl` runaway growth
caught.** Rotation was hardcoded to `.log` files, and several branches write
`.jsonl` logs via raw `open(path, "a")` appenders that bypass prax entirely —
`hooks/logs/engine.jsonl` had grown to 63 MB, `backup/logs/operations.jsonl`
to 31 MB, `trigger/logs/medic_suppressed.log` to 7 MB, all unrotated. The
log-watchdog safety net also only scanned `system_logs/*.log`. @prax extended
it: `scan_branch_log_files()` sweeps every `src/aipass/*/logs/` for `.log` +
`.jsonl` (WARN at 1 MB unrotated, CRITICAL at 10 MB),
`enforce_branch_log_limits()` truncates flagged files to the last 5000 lines,
and `drone @prax log-audit` now reports both system and branch scopes. 11 new
tests, full prax suite 947 green. The raw-appender writers themselves still
need per-owner caps — routed to @hooks, @backup, @trigger. (built by @prax)
---
## [2026-07-05]
### Fixed
+1 -1
View File
@@ -144,7 +144,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing
```
```bash
drone @seedgo audit my_project # Run quality checks on everything
drone @seedgo audit aipass # Run quality checks on everything
drone @flow create . "Refactor auth module" # Create a work plan
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.6.1"
version = "2.7.0"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
+91 -15
View File
@@ -5,10 +5,12 @@
# On interactive terminals it then chains into `aipass init run` to scaffold a first
# project (DPLAN-0234: one command does setup + init).
#
# Usage: ./setup.sh [--no-init] [--with-init] [--project <dir>]
# Usage: ./setup.sh [--no-init] [--with-init] [--project <dir>] [--no-symlink] [--force-symlink]
# --no-init skip the first-project init chain
# --with-init force the init chain even headless (init runs --non-interactive)
# --project <dir> first-project directory (default: ~/aipass-project)
# --no-symlink do not create/modify global drone/aipass CLI symlinks
# --force-symlink repoint a global symlink even if it points at a different install (#660)
#
set -euo pipefail
@@ -39,6 +41,8 @@ esac
# default (auto) chains into init on interactive terminals only — CI/headless skip.
RUN_INIT="auto"
INIT_PROJECT=""
SKIP_SYMLINK="no"
FORCE_SYMLINK="no"
PREV_ARG=""
for arg in "$@"; do
if [ "$PREV_ARG" = "--project" ]; then
@@ -47,10 +51,12 @@ for arg in "$@"; do
continue
fi
case "$arg" in
--no-init) RUN_INIT="no" ;;
--with-init) RUN_INIT="yes" ;;
--project=*) INIT_PROJECT="${arg#--project=}" ;;
--project) PREV_ARG="--project" ;;
--no-init) RUN_INIT="no" ;;
--with-init) RUN_INIT="yes" ;;
--no-symlink) SKIP_SYMLINK="yes" ;;
--force-symlink) FORCE_SYMLINK="yes" ;;
--project=*) INIT_PROJECT="${arg#--project=}" ;;
--project) PREV_ARG="--project" ;;
*) echo "WARN: unknown argument '$arg' (ignored)" ;;
esac
done
@@ -218,8 +224,8 @@ fi
echo "Upgrading pip ..."
"$VENV_PYTHON" -m pip install --upgrade pip --quiet
echo "Installing aipass in editable mode (with dev + memory extras) ..."
"$VENV_PYTHON" -m pip install -e ".[dev,memory]" --quiet
echo "Installing aipass in editable mode (with dev + memory extras) — this can take a few minutes while the memory wheels build ..."
"$VENV_PYTHON" -m pip install -e ".[dev,memory]"
# --- Detect shadowing drone installs (Windows) ---
# Issues #317 + #321: system-Python pip or legacy npm aipass-drone can shadow venv drone.exe.
@@ -662,6 +668,7 @@ else:
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
# SessionStart: cadence reset on startup/clear (handler skips resume itself)
aipass_hooks = {
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]},
@@ -696,6 +703,9 @@ aipass_hooks = {
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
],
"SessionStart": [
{"hooks": [{"type": "command", "command": f"{bridge} SessionStart:cadence_reset", "timeout": 30}]},
],
}
# Merge, don't replace (DPLAN-0234 Strand C): refresh every AIPass bridge entry
@@ -708,7 +718,15 @@ for event in set(existing_hooks) | set(aipass_hooks):
entry for entry in existing_hooks.get(event, [])
if "bridges/claude.py" not in json.dumps(entry)
]
merged_hooks[event] = aipass_hooks.get(event, []) + user_entries
merged = aipass_hooks.get(event, []) + user_entries
# Never emit an empty hook event. If this event had only stale AIPass bridge
# entries (no current aipass_hooks definition AND no user-wired hooks), the
# filter above orphans it to [] — a half-wired event that fires nothing,
# written silently. Drop the key instead and say so, so the state stays honest.
if not merged:
print(f" ! dropped orphaned hook event (no live entries): {event}")
continue
merged_hooks[event] = merged
settings["hooks"] = merged_hooks
# Inject AIPASS_HOME into env block so dispatched agents find AIPass
@@ -786,6 +804,16 @@ else
echo "Skipping Claude hooks (bridge not found at src/aipass/hooks/apps/handlers/bridges/claude.py)"
fi
# --- Install claude() boot shim (attach-if-live / start-in-tmux) ---
# Ships via the .gitignore negation (#666). Idempotent: the installer checks for
# its marker before appending to the shell rc, and resolves the venv Python from
# its own location (POSIX/Windows/fallback). Composes with presence_gate seeding.
BOOT_SHIM="$SCRIPT_DIR/src/aipass/hooks/tools/install_boot_shim.sh"
if [ -f "$BOOT_SHIM" ]; then
echo "Installing claude() boot shim ..."
bash "$BOOT_SHIM" || echo " boot shim install skipped (non-fatal)"
fi
# --- Install Claude Code commands (provider level) ---
# memo.md belongs at provider level — works in all projects.
# prep.md stays at repo root only — it's AIPass-specific.
@@ -952,8 +980,42 @@ else
fi
# --- Create global symlinks for CLI tools (Linux/macOS only) ---
# #660: never SILENTLY hijack a global 'drone'/'aipass' that points at a
# DIFFERENT install. safe_symlink skips a different-target link (loud warning)
# unless --force-symlink; --no-symlink opts out of symlinking entirely.
SYMLINK_SKIPPED=0
safe_symlink() {
# safe_symlink <src> <dest> [sudo] -> 0 linked · 1 skipped(diff target) · 2 ln failed
local src="$1" dest="$2" use_sudo="${3:-}" existing=""
if [ -L "$dest" ]; then
existing="$(readlink "$dest" 2>/dev/null)"
elif [ -e "$dest" ]; then
existing="$dest (real file, not a symlink)"
fi
if [ -n "$existing" ] && [ "$existing" != "$src" ]; then
if [ "$FORCE_SYMLINK" != "yes" ]; then
echo " SKIP $dest — already points at a different install:"
echo " $existing"
echo " Not repointing (would hijack your existing '$(basename "$dest")'); PATH keeps the above."
echo " Re-run 'aipass install' with --force-symlink to repoint here, or --no-symlink to skip quietly."
SYMLINK_SKIPPED=$((SYMLINK_SKIPPED + 1))
return 1
fi
echo " WARNING: repointing $dest"
echo " from $existing"
echo " to $src (--force-symlink)"
fi
if [ -n "$use_sudo" ]; then
$use_sudo ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
fi
ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
}
echo ""
if [ "$IS_WINDOWS" -eq 1 ]; then
if [ "$SKIP_SYMLINK" = "yes" ]; then
echo "Skipping global CLI symlinks (--no-symlink)."
echo " 'drone'/'aipass' resolve from $SCRIPT_DIR/.venv/bin — add it to PATH to use them."
elif [ "$IS_WINDOWS" -eq 1 ]; then
echo "Windows: drone available via PATH (set above)"
elif [ "$IS_MACOS" -eq 1 ]; then
# Mac: symlink into ~/.local/bin (user-writable, no sudo needed).
@@ -965,9 +1027,11 @@ elif [ "$IS_MACOS" -eq 1 ]; then
for cmd in drone aipass; do
if [ -f "$VENV_BIN/$cmd" ]; then
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
rc=0
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
if [ "$rc" -eq 0 ]; then
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
else
elif [ "$rc" -eq 2 ]; then
echo " WARN: Could not create symlink for $cmd"
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
fi
@@ -980,14 +1044,20 @@ else
for cmd in drone aipass; do
if [ -f "$VENV_BIN/$cmd" ]; then
if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then
rc=0
safe_symlink "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" "sudo" || rc=$?
if [ "$rc" -eq 0 ]; then
echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd"
LINUX_SYMLINK_DIR="/usr/local/bin"
elif [ "$rc" -eq 1 ]; then
: # skipped a different install — safe_symlink explained; do NOT fall back
else
# Fallback: user-local bin (no sudo needed)
# sudo/ln failed (e.g. no sudo) — fall back to user-local bin
LOCAL_BIN="$HOME/.local/bin"
mkdir -p "$LOCAL_BIN"
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
rc=0
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
if [ "$rc" -eq 0 ]; then
echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead"
LINUX_SYMLINK_DIR="$LOCAL_BIN"
# Ensure ~/.local/bin is on PATH
@@ -997,7 +1067,7 @@ else
echo " ~/.local/bin added to PATH in $PROFILE"
fi
export PATH="$HOME/.local/bin:$PATH"
else
elif [ "$rc" -eq 2 ]; then
echo " WARN: Could not create symlink for $cmd"
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
fi
@@ -1006,6 +1076,12 @@ else
done
fi
if [ "$SYMLINK_SKIPPED" -gt 0 ]; then
echo ""
echo " NOTE: $SYMLINK_SKIPPED global symlink(s) left untouched (pointed at a different install)."
echo " Your existing 'drone'/'aipass' still work. Use --force-symlink to repoint them here."
fi
# --- Result ---
echo ""
if [ "$FAIL" -eq 0 ]; then
+1 -1
View File
@@ -3,4 +3,4 @@
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
"""
__version__ = "2.6.1"
__version__ = "2.7.0"
+1 -1
View File
@@ -23,7 +23,7 @@
{
"file": "apps/handlers/dispatch/daemon.py",
"standard": "deep_nesting",
"reason": "3 functions: check_inbox_for_dispatch() depth 4 (priority scanning with business logic), run_daemon() depth 4 (main daemon loop), _check_lock() depth 4 (lock validation + PID liveness + cleanup)"
"reason": "run_daemon() depth 5 (main daemon loop with retry + signal handling)"
},
{
"file": "apps/handlers/dispatch/wake.py",
+7
View File
@@ -243,6 +243,13 @@ def main():
error("No modules found")
return 1
if remaining_args and remaining_args[0] in ["--help", "-h"]:
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print_help()
return 0
# Route command
if route_command(command, remaining_args, modules):
return 0
@@ -349,5 +349,7 @@ if __name__ == "__main__":
console.print()
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
from aipass.cli.apps.modules import error as cli_error
cli_error(f"Error: {e}")
raise
@@ -86,6 +86,56 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
return False
def _pid_alive_windows(pid: int) -> bool:
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
return exit_code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
def _pid_alive(pid: int) -> bool:
"""Return True if the process is alive."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[daemon] PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
except ProcessLookupError as exc:
logger.info("[daemon] PID %s not found: %s", pid, exc)
return False
except PermissionError as exc:
logger.info("[daemon] PID %s permission denied (alive): %s", pid, exc)
return True
except OSError as exc:
logger.info("[daemon] PID %s os.kill error (assuming dead): %s", pid, exc)
return False
return True
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
@@ -96,14 +146,9 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
data = json.load(f)
pid = data.get("pid")
if pid is not None:
try:
os.kill(pid, 0)
return data # Process alive, lock valid
except ProcessLookupError:
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
except PermissionError as e:
logger.warning("[daemon] Lock PID %s permission error: %s", pid, e)
return data # Process exists, can't signal
if _pid_alive(pid):
return data
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
# Stale lock — check age (10 min timeout)
ts = data.get("timestamp", "")
if ts:
@@ -214,15 +259,10 @@ def _write_pid_file() -> bool:
# PID file exists — check if the owning process is alive
try:
old_pid = int(DAEMON_PID_FILE.read_text().strip())
try:
os.kill(old_pid, 0)
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
return False
except ProcessLookupError:
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
except PermissionError:
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
if _pid_alive(old_pid):
logger.info("Another daemon already running (PID %s). Exiting.", old_pid)
return False
logger.info("Removing stale PID file (PID %s is dead)", old_pid)
except (ValueError, OSError):
logger.info("Corrupt PID file — removing")
@@ -474,18 +514,74 @@ def is_protected_branch(branch_email: str) -> bool:
return branch_email == "@devpulse"
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
if sys.platform != "linux":
return "interactive"
def _get_pid_cwd(pid_str: str) -> Optional[str]:
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
if sys.platform == "linux":
try:
return os.readlink(f"/proc/{pid_str}/cwd")
except (OSError, PermissionError):
logger.info("[daemon] Cannot read cwd for PID %s", pid_str)
return None
if sys.platform == "darwin":
return _get_pid_cwd_darwin(pid_str)
logger.info("[daemon] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
return None
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
"""macOS: get process cwd via lsof."""
try:
with open(f"/proc/{pid_str}/environ", "rb") as f:
data = f.read()
for entry in data.split(b"\0"):
if entry.startswith(b"AIPASS_SESSION_TYPE="):
return entry.split(b"=", 1)[1].decode("utf-8")
except (OSError, PermissionError):
logger.info("Cannot read session type for PID %s", pid_str)
result = subprocess.run(
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
logger.info("[daemon] Cannot read cwd for PID %s on macOS", pid_str)
return None
if result.returncode != 0:
return None
for line in result.stdout.strip().split("\n"):
if line.startswith("n/"):
return line[1:]
return None
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
if sys.platform == "linux":
try:
with open(f"/proc/{pid_str}/environ", "rb") as f:
data = f.read()
for entry in data.split(b"\0"):
if entry.startswith(b"AIPASS_SESSION_TYPE="):
return entry.split(b"=", 1)[1].decode("utf-8")
except (OSError, PermissionError):
logger.info("[daemon] Cannot read session type for PID %s", pid_str)
return "interactive"
if sys.platform == "darwin":
return _read_session_type_darwin(pid_str)
return "interactive"
def _read_session_type_darwin(pid_str: str) -> str:
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
try:
result = subprocess.run(
["ps", "-p", pid_str, "-wwE", "-o", "command="],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
logger.info("[daemon] Cannot read session type for PID %s on macOS", pid_str)
return "interactive"
if result.returncode != 0:
return "interactive"
for token in result.stdout.split():
if token.startswith("AIPASS_SESSION_TYPE="):
return token.split("=", 1)[1]
return "interactive"
@@ -494,35 +590,25 @@ _NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
def _is_branch_occupied(branch_path: Path) -> bool:
"""
Check if an interactive Claude session is running in this branch.
Only interactive sessions block dispatch. Telegram, dispatched, and daemon
sessions are idle/background and should not prevent new agent spawns.
"""
resolved = branch_path.resolve()
"""Check if an interactive Claude session is running in this branch."""
resolved = str(branch_path.resolve())
try:
result = subprocess.run(["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5)
if result.returncode != 0:
return False
for pid_str in result.stdout.strip().split("\n"):
pid_str = pid_str.strip()
if not pid_str:
continue
try:
if sys.platform != "linux":
continue
cwd = os.readlink(f"/proc/{pid_str}/cwd")
if Path(cwd).resolve() == resolved:
session_type = _read_session_type(pid_str)
if session_type not in _NON_BLOCKING_SESSION_TYPES:
return True
except (OSError, PermissionError, ValueError):
logger.info("Cannot read cwd for PID %s", pid_str)
cwd = _get_pid_cwd(pid_str)
if cwd is None:
continue
if str(Path(cwd).resolve()) == resolved:
session_type = _read_session_type(pid_str)
if session_type not in _NON_BLOCKING_SESSION_TYPES:
return True
except Exception:
logger.info("Failed to check branch occupancy for %s", branch_path)
logger.info("[daemon] Failed to check branch occupancy for %s", branch_path)
return False
@@ -80,6 +80,86 @@ def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
return create_identified_connection(socket_path, secret_path, branch_name)
MAX_WAKE_DEPTH = 3
def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str) -> str:
"""Wake the dispatcher back after target completion.
Wake-back is owner-only: only the project owner (sealed registry)
gets woken. Non-owners silently skipped.
Returns a result tag for the dispatch_wake.log:
success, blocked_occupied, blocked_locked, blocked_depth,
skipped_sender, skipped_not_owner, failed
"""
if not sender or not sender.strip():
logger.info("[monitor] Wake-back skipped — no sender")
return "skipped_sender"
normalized = f"@{sender.lstrip('@').lower()}"
try:
from aipass.spawn.apps.handlers.registry import is_owner
except ImportError:
logger.warning("[monitor] Wake-back skipped — is_owner import failed")
return "failed"
if not is_owner(normalized):
logger.info("[monitor] Wake-back skipped — sender %s is not project owner", sender)
return "skipped_not_owner"
depth = int(os.environ.get("AIPASS_WAKE_DEPTH", "0"))
if depth >= MAX_WAKE_DEPTH:
logger.warning("[monitor] Wake-back skipped — depth %d >= max %d", depth, MAX_WAKE_DEPTH)
return "blocked_depth"
try:
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
os.environ["AIPASS_WAKE_DEPTH"] = str(depth + 1)
wake_status, success = wake_branch(sender, auto=True, sender="@ai_mail")
if success:
logger.info("[monitor] Wake-back: %s woken after %s completed (exit %d)", sender, branch_email, exit_code)
return "success"
summary = wake_status.summary
lower = summary.lower()
if "interactive" in lower or "occupancy" in lower or "occupied" in lower:
logger.info("[monitor] Wake-back blocked — sender %s has interactive session: %s", sender, summary)
return "blocked_occupied"
if "active agent" in lower or "lock" in lower:
logger.info("[monitor] Wake-back blocked — sender %s has active lock: %s", sender, summary)
return "blocked_locked"
logger.info("[monitor] Wake-back: %s not woken — %s", sender, summary)
return "failed"
except Exception as e:
logger.warning("[monitor] Wake-back failed for %s: %s", sender, e)
return "failed"
def _log_wake_result(branch_email: str, sender: str, exit_code: int, result: str, lock_file: str):
"""Append a wake-back result line to dispatch_wake.log under target's logs/."""
lock_path = Path(lock_file).resolve()
logs_dir = lock_path.parent.parent / "logs"
log_file = logs_dir / "dispatch_wake.log"
try:
logs_dir.mkdir(parents=True, exist_ok=True)
line = (
f"{time.strftime('%Y-%m-%dT%H:%M:%S')}"
f" target={branch_email}"
f" sender={sender}"
f" exit_code={exit_code}"
f" wake_result={result}\n"
)
with open(log_file, "a", encoding="utf-8") as f:
f.write(line)
except OSError as e:
logger.info("[monitor] Failed to write dispatch_wake.log: %s", e)
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
"""Send return-to-sender bounce email via drone."""
subject = f"BOUNCE: Dispatch to {branch_email} failed"
@@ -585,6 +665,10 @@ def main():
except Exception:
logger.info("[monitor] Desktop notification unavailable")
# ─── Wake-back: wake the dispatcher ────────────────────
wake_result = _wake_sender(sender, branch_email, exit_code, lock_file)
_log_wake_result(branch_email, sender, exit_code, wake_result, lock_file)
sys.exit(0 if exit_code == 0 else 1)
+133 -41
View File
@@ -141,6 +141,34 @@ def _read_json(filepath: Path) -> Optional[dict]:
return None
def _pid_alive_windows(pid: int) -> bool:
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
return exit_code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
def _check_lock(branch_path: Path) -> Optional[dict]:
"""Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
@@ -151,14 +179,9 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
data = json.load(f)
pid = data.get("pid")
if pid is not None:
try:
os.kill(pid, 0)
return data # Process alive, lock valid
except ProcessLookupError:
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
except PermissionError as e:
logger.warning("[wake] Lock PID %s permission error: %s", pid, e)
return data # Process exists but can't signal — treat as active
if _check_pid_alive(pid):
return data
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
# Stale lock — check age (10 min timeout)
ts = data.get("timestamp", "")
if ts:
@@ -210,18 +233,74 @@ def _load_config() -> dict:
return config
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
if sys.platform != "linux":
return "interactive"
def _get_pid_cwd(pid_str: str) -> Optional[str]:
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
if sys.platform == "linux":
try:
return os.readlink(f"/proc/{pid_str}/cwd")
except (OSError, PermissionError):
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
return None
if sys.platform == "darwin":
return _get_pid_cwd_darwin(pid_str)
logger.info("[wake] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
return None
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
"""macOS: get process cwd via lsof."""
try:
with open(f"/proc/{pid_str}/environ", "rb") as f:
data = f.read()
for entry in data.split(b"\0"):
if entry.startswith(b"AIPASS_SESSION_TYPE="):
return entry.split(b"=", 1)[1].decode("utf-8")
except (OSError, PermissionError):
logger.info("[wake] Cannot read session type for PID %s", pid_str)
result = subprocess.run(
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
logger.info("[wake] Cannot read cwd for PID %s on macOS", pid_str)
return None
if result.returncode != 0:
return None
for line in result.stdout.strip().split("\n"):
if line.startswith("n/"):
return line[1:]
return None
def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
if sys.platform == "linux":
try:
with open(f"/proc/{pid_str}/environ", "rb") as f:
data = f.read()
for entry in data.split(b"\0"):
if entry.startswith(b"AIPASS_SESSION_TYPE="):
return entry.split(b"=", 1)[1].decode("utf-8")
except (OSError, PermissionError):
logger.info("[wake] Cannot read session type for PID %s", pid_str)
return "interactive"
if sys.platform == "darwin":
return _read_session_type_darwin(pid_str)
return "interactive"
def _read_session_type_darwin(pid_str: str) -> str:
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
try:
result = subprocess.run(
["ps", "-p", pid_str, "-wwE", "-o", "command="],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
logger.info("[wake] Cannot read session type for PID %s on macOS", pid_str)
return "interactive"
if result.returncode != 0:
return "interactive"
for token in result.stdout.split():
if token.startswith("AIPASS_SESSION_TYPE="):
return token.split("=", 1)[1]
return "interactive"
@@ -240,17 +319,13 @@ def _is_branch_occupied(branch_path: Path) -> bool:
pid_str = pid_str.strip()
if not pid_str:
continue
try:
if sys.platform != "linux":
continue
cwd = os.readlink(f"/proc/{pid_str}/cwd")
if str(Path(cwd).resolve()) == resolved:
session_type = _read_session_type(pid_str)
if session_type not in _NON_BLOCKING_SESSION_TYPES:
return True
except (OSError, PermissionError, ValueError):
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
cwd = _get_pid_cwd(pid_str)
if cwd is None:
continue
if str(Path(cwd).resolve()) == resolved:
session_type = _read_session_type(pid_str)
if session_type not in _NON_BLOCKING_SESSION_TYPES:
return True
except (subprocess.SubprocessError, OSError):
logger.info("[wake] Failed to check branch occupancy")
return False
@@ -273,21 +348,38 @@ def _clean_zombies() -> int:
def _check_pid_alive(pid: int) -> bool:
"""Check if a process is alive (not zombie)."""
if sys.platform == "win32":
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[wake] PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
# Also verify not zombie via /proc (Linux only)
if sys.platform == "linux":
with open(f"/proc/{pid}/status", "r") as f:
for line in f:
if line.startswith("State:"):
return "Z" not in line
return True
except (ProcessLookupError, FileNotFoundError) as e:
logger.warning("[wake] PID %s not found: %s", pid, e)
except ProcessLookupError as exc:
logger.warning("[wake] PID %s not found: %s", pid, exc)
return False
except PermissionError as e:
logger.warning("[wake] PID %s permission denied: %s", pid, e)
return True # Exists but can't check — assume alive
except PermissionError as exc:
logger.warning("[wake] PID %s permission denied: %s", pid, exc)
return True
except OSError as exc:
logger.warning("[wake] PID %s os.kill error (assuming dead): %s", pid, exc)
return False
if sys.platform == "linux" and _is_zombie_linux(pid):
return False
return True
def _is_zombie_linux(pid: int) -> bool:
"""Return True if PID is a zombie (Linux /proc/status check)."""
try:
with open(f"/proc/{pid}/status", "r") as f:
for line in f:
if line.startswith("State:"):
return "Z" in line
except FileNotFoundError as exc:
logger.warning("[wake] PID %s /proc not found: %s", pid, exc)
return False
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
@@ -173,10 +173,10 @@ if __name__ == "__main__":
console.print(" - format_email_list_item(index, email_data, show_unread) -> str")
console.print()
console.print("HANDLER CHARACTERISTICS:")
console.print(" ✓ Independent - no module dependencies")
console.print(" ✓ Can import Prax (service provider)")
console.print(" ✓ Pure business logic")
console.print(" ✗ CANNOT import parent modules")
console.print(" [green]+[/green] Independent - no module dependencies")
console.print(" [green]+[/green] Can import Prax (service provider)")
console.print(" [green]+[/green] Pure business logic")
console.print(" [dim]-[/dim] CANNOT import parent modules")
console.print()
console.print("USAGE FROM MODULES:")
console.print(" from ai_mail.apps.handlers.email.format import format_email_preview")
@@ -134,10 +134,10 @@ if __name__ == "__main__":
console.print(" - load_inbox(inbox_file) -> Dict")
console.print()
console.print("HANDLER CHARACTERISTICS:")
console.print(" ✓ Independent - no module dependencies")
console.print(" ✓ Can import Prax (service provider)")
console.print(" ✓ Pure business logic")
console.print(" ✗ CANNOT import parent modules")
console.print(" [green]+[/green] Independent - no module dependencies")
console.print(" [green]+[/green] Can import Prax (service provider)")
console.print(" [green]+[/green] Pure business logic")
console.print(" [dim]-[/dim] CANNOT import parent modules")
console.print()
console.print("USAGE FROM MODULES:")
console.print(" from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox")
@@ -305,10 +305,10 @@ if __name__ == "__main__":
console.print(" - get_branch_info_from_registry(branch_path) -> Optional[Dict]")
console.print()
console.print("HANDLER CHARACTERISTICS:")
console.print(" ✓ Independent - no module dependencies")
console.print(" ✓ Can import Prax (service provider)")
console.print(" ✓ Pure business logic")
console.print(" ✗ CANNOT import parent modules")
console.print(" [green]+[/green] Independent - no module dependencies")
console.print(" [green]+[/green] Can import Prax (service provider)")
console.print(" [green]+[/green] Pure business logic")
console.print(" [dim]-[/dim] CANNOT import parent modules")
console.print()
console.print("DETECTION FLOW:")
console.print(" 1. Get current working directory (PWD)")
+2 -56
View File
@@ -14,7 +14,6 @@ Delegates all business logic to handlers.
"""
import os
import subprocess
import sys
from pathlib import Path
from typing import List
@@ -48,7 +47,6 @@ DISPATCH (send + wake):
drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake
drone @ai_mail dispatch @branch "Subject" "Body" --model opus # Send + wake with Opus
drone @ai_mail dispatch @branch "Subject" "Body" --no-memory-save
drone @ai_mail dispatch @branch "Subject" "Body" --no-watchdog # Skip auto-watchdog
WAKE ONLY:
drone @ai_mail dispatch wake @branch # Wake with default inbox check
@@ -226,7 +224,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
# Parse flags
use_fresh = False
no_memory_save = False
no_watchdog = False
from_branch = None
use_model = None
filtered = []
@@ -241,7 +238,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
i += 1
continue
if args[i] == "--no-watchdog":
no_watchdog = True
i += 1
continue
if args[i] == "--from" and i + 1 < len(args):
@@ -349,62 +345,12 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
if not wake_ok:
logger.warning("[dispatch] Wake failed for %s — email was sent", target)
error(f"Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}")
elif not no_watchdog:
_spawn_watchdog(target)
else:
console.print(f"[dim]Wake-back enabled — sender will be woken when {target} completes (if available)[/dim]")
return True
def _spawn_watchdog(target: str) -> None:
"""Auto-spawn devpulse watchdog as a detached background process."""
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
from aipass.ai_mail.apps.handlers.paths import find_repo_root
devpulse_info = get_branch_by_email("@devpulse")
if not devpulse_info:
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse not in registry")
return
_repo_root = find_repo_root()
devpulse_path = devpulse_info.get("path", "")
if not devpulse_path:
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse has no path")
return
devpulse_dir = Path(devpulse_path)
if not devpulse_dir.is_absolute():
devpulse_dir = _repo_root / devpulse_dir
if not devpulse_dir.is_dir():
logger.warning("[dispatch] Cannot spawn watchdog — devpulse dir not found: %s", devpulse_dir)
return
cmd = ["drone", "@devpulse", "watchdog", "agent", target]
spawn_env = os.environ.copy()
local_bin = str(Path.home() / ".local" / "bin")
if local_bin not in spawn_env.get("PATH", ""):
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
_detach_kwargs: dict = {}
if sys.platform == "win32":
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
_detach_kwargs["start_new_session"] = True
try:
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=str(devpulse_dir),
env=spawn_env,
**_detach_kwargs,
)
console.print(f"[green]Watchdog armed for {target}[/green]")
except Exception as e:
logger.warning("[dispatch] Watchdog spawn failed for %s: %s", target, e)
def _orchestrate_daemon() -> bool:
"""Orchestrate daemon startup."""
logger.info("[dispatch] Starting dispatch daemon")
+20 -39
View File
@@ -9,10 +9,11 @@
"""Tests for dispatch daemon handler -- config loading, state management, inbox scanning."""
import json
import os
import sys
import pytest
from datetime import datetime, date, timedelta
from unittest.mock import patch
from unittest.mock import MagicMock, mock_open, patch
import aipass.ai_mail.apps.handlers.dispatch.daemon as daemon_mod
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
@@ -25,6 +26,17 @@ from aipass.ai_mail.apps.handlers.dispatch.daemon import (
get_registered_branches,
check_inbox_for_dispatch,
is_protected_branch,
_handle_signal,
_check_lock,
_acquire_lock,
_is_registered_sender,
poll_cycle,
_write_pid_file,
_remove_pid_file,
_read_session_type,
_is_branch_occupied,
spawn_agent,
run_daemon,
)
@@ -764,26 +776,6 @@ def test_poll_cycle_absolute_path_unchanged(tmp_path, monkeypatch):
assert spawned_paths[0] == branch_dir
# ---- Additional imports for new tests --------------------------------
import os
from unittest.mock import MagicMock, mock_open
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
_handle_signal,
_check_lock,
_acquire_lock,
_is_registered_sender,
poll_cycle,
_write_pid_file,
_remove_pid_file,
_read_session_type,
_is_branch_occupied,
spawn_agent,
run_daemon,
)
# ---- _handle_signal tests --------------------------------------
@@ -814,7 +806,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
@@ -823,17 +815,14 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
def test_check_lock_dead_pid(tmp_path, monkeypatch):
"""Lock with dead PID (ProcessLookupError) is cleaned up."""
"""Lock with dead PID is cleaned up."""
lock_dir = tmp_path / ".ai_mail.local"
lock_dir.mkdir(parents=True)
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
@@ -849,10 +838,7 @@ def test_check_lock_permission_error(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_permission(pid, sig):
raise PermissionError("Operation not permitted")
monkeypatch.setattr(os, "kill", _raise_permission)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
@@ -869,10 +855,7 @@ def test_check_lock_stale_over_10min_removed(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": old_time}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
@@ -889,10 +872,7 @@ def test_check_lock_stale_under_10min_dead_pid_removed(tmp_path, monkeypatch):
lock_data = {"pid": 99999, "timestamp": recent_time}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
def _raise_process_lookup(pid, sig):
raise ProcessLookupError("No such process")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
@@ -1015,6 +995,7 @@ def test_write_pid_file_existing_dead_pid(tmp_path, monkeypatch):
def test_write_pid_file_existing_permission_error(tmp_path, monkeypatch):
"""Existing PID file with PermissionError on kill returns False."""
monkeypatch.setattr("sys.platform", "linux")
pid_file = tmp_path / "daemon.pid"
pid_file.write_text("888888", encoding="utf-8")
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
+18 -189
View File
@@ -16,8 +16,6 @@ All handler dependencies are mocked -- these tests verify orchestration
logic, not business logic.
"""
import subprocess
import sys
from contextlib import ExitStack
import pytest
@@ -971,172 +969,18 @@ class TestPrintIntrospection:
# ===========================================================================
# _spawn_watchdog
# Wake-back messaging (TDPLAN-0012 — retired _spawn_watchdog)
# ===========================================================================
class TestSpawnWatchdog:
"""Tests for _spawn_watchdog."""
def test_spawns_detached_subprocess(self, monkeypatch, tmp_path):
"""Successful watchdog spawn calls Popen with correct args."""
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
devpulse_dir.mkdir(parents=True)
class TestWakeBackMessaging:
"""Tests for honest wake-back messaging after watchdog retirement."""
def test_wake_back_message_on_successful_wake(self, monkeypatch):
"""Successful send + wake prints wake-back enabled message."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
popen_calls: list[dict] = []
mock_popen = MagicMock()
def tracking_popen(cmd, **kwargs):
"""Capture Popen arguments."""
popen_calls.append({"cmd": cmd, **kwargs})
return mock_popen
with (
patch(
f"{_H_REG}.get_branch_by_email",
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
),
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
assert len(popen_calls) == 1
assert popen_calls[0]["cmd"] == ["drone", "@devpulse", "watchdog", "agent", "@flow"]
if sys.platform == "win32":
assert popen_calls[0].get("creationflags") == subprocess.CREATE_NEW_PROCESS_GROUP
assert "start_new_session" not in popen_calls[0]
else:
assert popen_calls[0]["start_new_session"] is True
assert popen_calls[0]["cwd"] == str(devpulse_dir)
combined = " ".join(printed)
assert "Watchdog armed for @flow" in combined
def test_devpulse_not_in_registry(self, monkeypatch):
"""No spawn when @devpulse not found in registry."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
with (
patch(f"{_H_REG}.get_branch_by_email", return_value=None),
patch(f"{MOD}.subprocess.Popen") as mock_popen,
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
mock_popen.assert_not_called()
def test_devpulse_no_path(self, monkeypatch):
"""No spawn when @devpulse has empty path."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
with (
patch(
f"{_H_REG}.get_branch_by_email",
return_value={"email": "@devpulse", "path": ""},
),
patch(f"{MOD}.subprocess.Popen") as mock_popen,
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
mock_popen.assert_not_called()
def test_devpulse_dir_missing(self, monkeypatch, tmp_path):
"""No spawn when devpulse directory doesn't exist."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
with (
patch(
f"{_H_REG}.get_branch_by_email",
return_value={"email": "@devpulse", "path": str(tmp_path / "nonexistent")},
),
patch(f"{MOD}.subprocess.Popen") as mock_popen,
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
mock_popen.assert_not_called()
def test_popen_failure_warns_but_does_not_raise(self, monkeypatch, tmp_path):
"""Popen failure logs warning but doesn't propagate."""
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
devpulse_dir.mkdir(parents=True)
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
with (
patch(
f"{_H_REG}.get_branch_by_email",
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
),
patch(f"{MOD}.subprocess.Popen", side_effect=FileNotFoundError("drone not found")),
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
# Should not raise — watchdog is optional
def test_relative_devpulse_path_resolved(self, monkeypatch, tmp_path):
"""Relative path from registry is resolved against repo root."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
popen_calls: list[dict] = []
def tracking_popen(cmd, **kwargs):
"""Capture Popen arguments."""
popen_calls.append({"cmd": cmd, **kwargs})
return MagicMock()
from aipass.ai_mail.apps.modules import dispatch as dispatch_mod
real_repo_root = dispatch_mod.Path(__file__).resolve().parents[4]
devpulse_dir = real_repo_root / "src" / "aipass" / "devpulse"
with (
patch(
f"{_H_REG}.get_branch_by_email",
return_value={"email": "@devpulse", "path": "src/aipass/devpulse"},
),
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
):
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
_spawn_watchdog("@flow")
if devpulse_dir.is_dir():
assert len(popen_calls) == 1
assert "devpulse" in popen_calls[0]["cwd"]
else:
assert len(popen_calls) == 0
class TestDispatchSendWatchdogIntegration:
"""Tests for watchdog integration in _orchestrate_dispatch_send."""
def test_watchdog_spawned_after_successful_wake(self, monkeypatch):
"""Watchdog is spawned after successful send + wake."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
watchdog_calls: list[str] = []
monkeypatch.setattr(
f"{MOD}._spawn_watchdog",
lambda target: watchdog_calls.append(target),
)
patches = _send_patches()
with patches:
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
@@ -1144,21 +988,17 @@ class TestDispatchSendWatchdogIntegration:
result = _orchestrate_dispatch_send(["@target", "Subject", "Body"])
assert result is True
assert watchdog_calls == ["@target"]
combined = " ".join(printed)
assert "Wake-back enabled" in combined
assert "Watchdog armed" not in combined
def test_watchdog_not_spawned_on_wake_failure(self, monkeypatch):
"""Watchdog is NOT spawned when wake fails."""
def test_no_wake_back_message_on_wake_failure(self, monkeypatch):
"""No wake-back message when wake fails."""
errors: list[str] = []
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
watchdog_calls: list[str] = []
monkeypatch.setattr(
f"{MOD}._spawn_watchdog",
lambda target: watchdog_calls.append(target),
)
mock_status = MagicMock()
mock_status.format.return_value = "WAKE FAILED"
patches = _send_patches(
@@ -1171,19 +1011,14 @@ class TestDispatchSendWatchdogIntegration:
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
assert watchdog_calls == []
combined = " ".join(printed)
assert "Wake-back enabled" not in combined
def test_no_watchdog_flag_skips_spawn(self, monkeypatch):
"""--no-watchdog flag prevents watchdog spawn."""
def test_no_watchdog_flag_still_accepted(self, monkeypatch):
"""--no-watchdog flag is consumed without error (backward compat)."""
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
watchdog_calls: list[str] = []
monkeypatch.setattr(
f"{MOD}._spawn_watchdog",
lambda target: watchdog_calls.append(target),
)
patches = _send_patches()
with patches:
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
@@ -1191,21 +1026,14 @@ class TestDispatchSendWatchdogIntegration:
result = _orchestrate_dispatch_send(["@target", "Subject", "Body", "--no-watchdog"])
assert result is True
assert watchdog_calls == []
def test_watchdog_not_spawned_on_send_failure(self, monkeypatch):
"""Watchdog is NOT spawned when send fails."""
def test_no_watchdog_message_on_send_failure(self, monkeypatch):
"""No wake-back message when send fails."""
errors: list[str] = []
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
printed: list[str] = []
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
watchdog_calls: list[str] = []
monkeypatch.setattr(
f"{MOD}._spawn_watchdog",
lambda target: watchdog_calls.append(target),
)
patches = _send_patches(
{
f"{_H_SEND}.send_to_single": MagicMock(return_value=(False, "error")),
@@ -1216,4 +1044,5 @@ class TestDispatchSendWatchdogIntegration:
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
assert watchdog_calls == []
combined = " ".join(printed)
assert "Wake-back enabled" not in combined
@@ -19,15 +19,18 @@ from unittest.mock import MagicMock
import aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor as mod
from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
MAX_WAKE_DEPTH,
_check_jsonl_activity,
_check_rate_limited,
_get_jsonl_projects_dir,
_is_sandbox_enabled,
_kill_process,
_log_wake_result,
_make_fresh_cmd,
_run_with_startup_check,
_send_bounce,
_snapshot_jsonl_sizes,
_wake_sender,
_wrap_for_sandbox,
main,
)
@@ -52,6 +55,13 @@ def _suppress_logger(monkeypatch):
monkeypatch.setattr(mod, "logger", MagicMock())
@pytest.fixture(autouse=True)
def _suppress_wake(monkeypatch):
"""Prevent _wake_sender from importing/calling real wake_branch in unrelated tests."""
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="skipped_sender"))
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
@pytest.fixture
def stderr_log(tmp_path):
"""Create a stderr log file and return its path string."""
@@ -1824,3 +1834,417 @@ finally:
finally:
broker.stop()
t.join(timeout=3)
# === Wake-back tests (TDPLAN-0012) ==========================================
class TestWakeSender:
"""_wake_sender guards and owner-allowlist dispatch."""
@pytest.fixture(autouse=True)
def _mock_is_owner(self, monkeypatch):
"""Default: is_owner returns False (non-owner). Tests override as needed."""
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=False),
)
def test_skips_empty_sender(self, monkeypatch):
"""Empty sender returns skipped_sender."""
monkeypatch.setattr(mod, "logger", MagicMock())
result = _wake_sender("", "@target", 0, "/fake/lock")
assert result == "skipped_sender"
def test_skips_whitespace_sender(self, monkeypatch):
"""Whitespace-only sender returns skipped_sender."""
monkeypatch.setattr(mod, "logger", MagicMock())
result = _wake_sender(" ", "@target", 0, "/fake/lock")
assert result == "skipped_sender"
def test_skips_non_owner_sender(self, monkeypatch):
"""Non-owner sender returns skipped_not_owner."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=False),
)
result = _wake_sender("@someagent", "@target", 0, "/fake/lock")
assert result == "skipped_not_owner"
def test_skips_ai_mail_when_not_owner(self, monkeypatch):
"""@ai_mail is not owner — skipped."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=False),
)
result = _wake_sender("@ai_mail", "@target", 0, "/fake/lock")
assert result == "skipped_not_owner"
def test_skips_human_when_not_owner(self, monkeypatch):
"""@human is not owner — skipped."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=False),
)
result = _wake_sender("@human", "@target", 0, "/fake/lock")
assert result == "skipped_not_owner"
def test_owner_passes_guard(self, monkeypatch):
"""Owner sender passes the is_owner guard and reaches wake_branch."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
mock_status = MagicMock()
mock_status.summary = "ok"
mock_wake = MagicMock(return_value=(mock_status, True))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
mock_wake,
)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "success"
mock_wake.assert_called_once()
def test_is_owner_called_with_normalized_sender(self, monkeypatch):
"""is_owner receives normalized @-prefixed lowercase sender."""
monkeypatch.setattr(mod, "logger", MagicMock())
mock_is_owner = MagicMock(return_value=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
mock_is_owner,
)
_wake_sender("DevPulse", "@target", 0, "/fake/lock")
mock_is_owner.assert_called_once_with("@devpulse")
def test_is_owner_import_failure(self, monkeypatch):
"""ImportError from is_owner returns failed."""
monkeypatch.setattr(mod, "logger", MagicMock())
import builtins
real_import = builtins.__import__
def fail_import(name, *args, **kwargs):
if name == "aipass.spawn.apps.handlers.registry":
raise ImportError("no spawn")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fail_import)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "failed"
def test_depth_cap_blocks(self, monkeypatch):
"""AIPASS_WAKE_DEPTH >= MAX_WAKE_DEPTH returns blocked_depth."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH))
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "blocked_depth"
def test_depth_cap_over_max_blocks(self, monkeypatch):
"""Depth above max also blocks."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH + 5))
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "blocked_depth"
def test_success_on_wake(self, monkeypatch):
"""Successful wake_branch call returns success."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
mock_status = MagicMock()
mock_status.summary = "ok"
mock_wake = MagicMock(return_value=(mock_status, True))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
mock_wake,
)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "success"
mock_wake.assert_called_once_with("@devpulse", auto=True, sender="@ai_mail")
def test_blocked_locked_on_lock_failure(self, monkeypatch):
"""wake_branch failing with lock-related message returns blocked_locked."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
mock_status = MagicMock()
mock_status.summary = "lock: Active agent (PID 1234)"
mock_wake = MagicMock(return_value=(mock_status, False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
mock_wake,
)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "blocked_locked"
def test_blocked_occupied_on_interactive(self, monkeypatch):
"""wake_branch failing with occupancy message returns blocked_occupied."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
mock_status = MagicMock()
mock_status.summary = "blocked: Cannot spawn — interactive session running"
mock_wake = MagicMock(return_value=(mock_status, False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
mock_wake,
)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "blocked_occupied"
def test_failed_on_exception(self, monkeypatch):
"""Exception during wake returns failed."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
MagicMock(side_effect=RuntimeError("broken")),
)
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert result == "failed"
def test_depth_incremented_before_wake(self, monkeypatch):
"""AIPASS_WAKE_DEPTH is incremented before calling wake_branch."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.setenv("AIPASS_WAKE_DEPTH", "1")
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
captured_depth = []
def capture_wake(*args, **kwargs):
captured_depth.append(os.environ.get("AIPASS_WAKE_DEPTH"))
mock_status = MagicMock()
mock_status.summary = "ok"
return mock_status, True
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
capture_wake,
)
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert captured_depth == ["2"]
def test_wake_called_on_failure_exit(self, monkeypatch):
"""Wake fires on non-zero exit code too."""
monkeypatch.setattr(mod, "logger", MagicMock())
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
MagicMock(return_value=True),
)
mock_status = MagicMock()
mock_status.summary = "ok"
mock_wake = MagicMock(return_value=(mock_status, True))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
mock_wake,
)
result = _wake_sender("@devpulse", "@target", 1, "/fake/lock")
assert result == "success"
mock_wake.assert_called_once()
def test_sender_normalization_for_is_owner(self, monkeypatch):
"""Sender with or without @ prefix is normalized before is_owner call."""
monkeypatch.setattr(mod, "logger", MagicMock())
mock_is_owner = MagicMock(return_value=False)
monkeypatch.setattr(
"aipass.spawn.apps.handlers.registry.is_owner",
mock_is_owner,
)
_wake_sender("devpulse", "@target", 0, "/fake/lock")
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
assert mock_is_owner.call_count == 2
for call in mock_is_owner.call_args_list:
assert call[0][0] == "@devpulse"
class TestLogWakeResult:
"""_log_wake_result writes to dispatch_wake.log."""
def test_creates_log_file(self, tmp_path):
"""Log file created under target's logs/ directory."""
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
lock.parent.mkdir(parents=True)
lock.write_text("{}", encoding="utf-8")
logs_dir = tmp_path / "branch" / "logs"
_log_wake_result("@target", "@sender", 0, "success", str(lock))
log_file = logs_dir / "dispatch_wake.log"
assert log_file.exists()
content = log_file.read_text(encoding="utf-8")
assert "target=@target" in content
assert "sender=@sender" in content
assert "exit_code=0" in content
assert "wake_result=success" in content
def test_appends_to_existing(self, tmp_path):
"""Subsequent calls append, not overwrite."""
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
lock.parent.mkdir(parents=True)
lock.write_text("{}", encoding="utf-8")
logs_dir = tmp_path / "branch" / "logs"
logs_dir.mkdir(parents=True)
log_file = logs_dir / "dispatch_wake.log"
log_file.write_text("existing line\n", encoding="utf-8")
_log_wake_result("@target", "@sender", 0, "success", str(lock))
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
assert len(lines) == 2
assert lines[0] == "existing line"
assert "wake_result=success" in lines[1]
def test_all_result_values(self, tmp_path):
"""All result enum values are logged correctly."""
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
lock.parent.mkdir(parents=True)
lock.write_text("{}", encoding="utf-8")
for result_tag in (
"success",
"blocked_occupied",
"blocked_locked",
"blocked_depth",
"skipped_sender",
"failed",
):
_log_wake_result("@t", "@s", 0, result_tag, str(lock))
log_file = tmp_path / "branch" / "logs" / "dispatch_wake.log"
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
assert len(lines) == 6
class TestWakeBackIntegration:
"""Wake-back wired into main() — fires after lock cleanup on both paths."""
def test_wake_called_on_success(self, monkeypatch, main_argv):
"""_wake_sender called with correct args after successful agent run."""
argv, lock_file, stderr_log = main_argv
wake_calls = []
def track_wake(sender, branch_email, exit_code, lf):
wake_calls.append((sender, branch_email, exit_code))
return "success"
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(mod, "_wake_sender", track_wake)
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert len(wake_calls) == 1
assert wake_calls[0] == ("@sender", "@test_branch", 0)
def test_wake_called_on_failure(self, monkeypatch, main_argv):
"""_wake_sender called after all attempts fail (in addition to bounce)."""
argv, lock_file, stderr_log = main_argv
wake_calls = []
mock_bounce = MagicMock()
def track_wake(sender, branch_email, exit_code, lf):
wake_calls.append((sender, branch_email, exit_code))
return "success"
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (1, False), (1, False)]))
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(mod, "_wake_sender", track_wake)
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
monkeypatch.setattr(
mod,
"time",
MagicMock(time=time.time, strftime=time.strftime, sleep=MagicMock()),
)
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
mock_bounce.assert_called_once()
assert len(wake_calls) == 1
assert wake_calls[0][2] != 0
def test_log_wake_result_called(self, monkeypatch, main_argv):
"""_log_wake_result called with wake result after main completes."""
argv, lock_file, stderr_log = main_argv
log_calls = []
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="success"))
monkeypatch.setattr(mod, "_log_wake_result", lambda *a: log_calls.append(a))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
assert len(log_calls) == 1
branch_email, sender, exit_code, result, lf = log_calls[0]
assert branch_email == "@test_branch"
assert sender == "@sender"
assert exit_code == 0
assert result == "success"
+125 -4
View File
@@ -20,7 +20,11 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
_read_json,
_check_lock,
_check_pid_alive,
_get_pid_cwd,
_get_pid_cwd_darwin,
_read_session_type,
_read_session_type_darwin,
_is_zombie_linux,
_clean_zombies,
_find_claude_bin,
resolve_branch,
@@ -138,6 +142,7 @@ def test_check_pid_alive_dead(monkeypatch):
def test_check_pid_alive_permission_error(monkeypatch):
"""PermissionError means process exists but cannot signal -- returns True."""
monkeypatch.setattr("sys.platform", "linux")
monkeypatch.setattr(os, "kill", _raise_permission)
assert _check_pid_alive(1) is True
@@ -201,11 +206,126 @@ def test_read_session_type_not_set(monkeypatch, tmp_path):
def test_read_session_type_non_linux(monkeypatch):
"""Non-linux platform returns 'interactive' immediately."""
monkeypatch.setattr("sys.platform", "darwin")
"""Non-linux, non-darwin platform returns 'interactive' immediately."""
monkeypatch.setattr("sys.platform", "win32")
assert _read_session_type("999") == "interactive"
def test_read_session_type_darwin_found(monkeypatch):
"""macOS: reads AIPASS_SESSION_TYPE from ps -wwE output."""
monkeypatch.setattr("sys.platform", "darwin")
class FakeResult:
returncode = 0
stdout = "/usr/bin/claude AIPASS_SESSION_TYPE=dispatched HOME=/Users/u"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
assert _read_session_type("123") == "dispatched"
def test_read_session_type_darwin_not_set(monkeypatch):
"""macOS: missing env var returns 'interactive'."""
monkeypatch.setattr("sys.platform", "darwin")
class FakeResult:
returncode = 0
stdout = "/usr/bin/claude HOME=/Users/u"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
assert _read_session_type("456") == "interactive"
def test_read_session_type_darwin_ps_failure(monkeypatch):
"""macOS: ps failure returns 'interactive'."""
assert _read_session_type_darwin("999") == "interactive"
# --- _get_pid_cwd tests ------------------------------------------------
def test_get_pid_cwd_linux(monkeypatch, tmp_path):
"""Linux: reads /proc/{pid}/cwd via readlink."""
monkeypatch.setattr("sys.platform", "linux")
target = str(tmp_path / "project")
monkeypatch.setattr(os, "readlink", lambda p: target)
assert _get_pid_cwd("100") == target
def test_get_pid_cwd_linux_oserror(monkeypatch):
"""Linux: OSError returns None."""
monkeypatch.setattr("sys.platform", "linux")
monkeypatch.setattr(os, "readlink", lambda p: (_ for _ in ()).throw(OSError("no proc")))
assert _get_pid_cwd("100") is None
def test_get_pid_cwd_darwin(monkeypatch, tmp_path):
"""macOS: reads cwd via lsof."""
monkeypatch.setattr("sys.platform", "darwin")
target = "/tmp/pytest-project"
class FakeResult:
returncode = 0
stdout = f"p100\nn{target}\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
assert _get_pid_cwd("100") == target
def test_get_pid_cwd_darwin_failure(monkeypatch):
"""macOS: lsof failure returns None."""
class FailedResult:
returncode = 1
stdout = ""
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FailedResult())
assert _get_pid_cwd_darwin("999") is None
def test_get_pid_cwd_unsupported_platform(monkeypatch):
"""Unsupported platform returns None."""
monkeypatch.setattr("sys.platform", "win32")
assert _get_pid_cwd("100") is None
# --- _is_zombie_linux tests --------------------------------------------
def test_is_zombie_linux_not_zombie(monkeypatch, tmp_path):
"""Non-zombie process returns False."""
status_file = tmp_path / "status"
status_file.write_text("Name:\tclaude\nState:\tS (sleeping)\nPid:\t42\n")
monkeypatch.setattr(
"builtins.open",
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
)
assert _is_zombie_linux(42) is False
def test_is_zombie_linux_zombie(monkeypatch, tmp_path):
"""Zombie process returns True."""
status_file = tmp_path / "status"
status_file.write_text("Name:\tclaude\nState:\tZ (zombie)\nPid:\t42\n")
monkeypatch.setattr(
"builtins.open",
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
)
assert _is_zombie_linux(42) is True
def test_is_zombie_linux_no_proc(monkeypatch):
"""Missing /proc entry returns False (not zombie, just gone)."""
_real_open = open
def _fake_open(path, *a, **kw):
if "/proc/99999/" in str(path):
raise FileNotFoundError(path)
return _real_open(path, *a, **kw)
monkeypatch.setattr("builtins.open", _fake_open)
assert _is_zombie_linux(99999) is False
# --- _check_lock tests ------------------------------------------------
@@ -222,7 +342,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 1234, "timestamp": "2026-03-29T10:00:00"}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
result = _check_lock(tmp_path)
assert result is not None
assert result["pid"] == 1234
@@ -235,7 +355,7 @@ def test_check_lock_dead_pid_removes_lock(tmp_path, monkeypatch):
lock_file = lock_dir / ".dispatch.lock"
lock_data = {"pid": 99999, "timestamp": "2026-03-29T10:00:00"}
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
monkeypatch.setattr(os, "kill", _raise_process_lookup)
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: False)
result = _check_lock(tmp_path)
assert result is None
assert not lock_file.exists()
@@ -257,6 +377,7 @@ def test_check_lock_stale_old_timestamp(tmp_path, monkeypatch):
def test_check_lock_permission_error_treated_active(tmp_path, monkeypatch):
"""Lock PID that raises PermissionError is treated as active."""
monkeypatch.setattr("sys.platform", "linux")
lock_dir = tmp_path / ".ai_mail.local"
lock_dir.mkdir(parents=True)
lock_file = lock_dir / ".dispatch.lock"
+50 -6
View File
@@ -18,6 +18,7 @@ Auto-discovery architecture:
import os
import sys
import importlib
import importlib.metadata
from pathlib import Path
from typing import List, Any
@@ -43,9 +44,13 @@ from aipass.prax import logger
MODULES_DIR = Path(__file__).parent / "modules"
_import_failures: dict[str, Exception] = {}
def discover_modules() -> List[Any]:
"""Auto-discover modules in modules/ directory."""
modules = []
_import_failures.clear()
if not MODULES_DIR.exists():
return modules
@@ -62,18 +67,25 @@ def discover_modules() -> List[Any]:
modules.append(module)
except Exception as e:
logger.error(f"[AIPASS] Failed to load module {module_name}: {e}")
_import_failures[file_path.stem] = e
return modules
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
"""Route command to appropriate module."""
"""Route command to appropriate module.
Returns True on success. Raises on handler crash so callers can
distinguish 'not found' (False) from 'found but broken'.
"""
for module in modules:
try:
if module.handle_command(command, args):
return True
except Exception as e:
logger.error(f"[AIPASS] Module {module.__name__} error: {e}")
mod_name = module.__name__.split(".")[-1]
logger.error(f"[AIPASS] Module {mod_name} crashed: {e}")
raise
return False
@@ -88,14 +100,20 @@ def main():
args = sys.argv[1:]
if len(args) > 0 and args[0] in ["--version", "-V"]:
print("aipass 0.1.0")
try:
version = importlib.metadata.version("aipass")
except importlib.metadata.PackageNotFoundError:
logger.info("[AIPASS] Package metadata not found, version unknown")
version = "unknown"
print(f"aipass {version}")
return 0
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
if show_root_help:
print(f"AIPASS - {len(modules)} modules discovered")
for module in modules:
name = module.__name__.split(".")[-1]
stem = module.__name__.split(".")[-1]
name = getattr(module, "COMMAND", stem)
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
print(f" {name:20} {desc}")
return 0
@@ -103,8 +121,34 @@ def main():
command = args[0]
remaining = args[1:] if len(args) > 1 else []
if route_command(command, remaining, modules):
return 0
# Subcommand --help guard: intercept before dispatch
if remaining and remaining[0] in ("--help", "-h"):
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print(f"Unknown command: {command}")
return 1
try:
if route_command(command, remaining, modules):
return 0
except Exception as e:
print(f"Error: '{command}' crashed: {e}")
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
return 1
if command.startswith("@"):
print(f"{command} is a drone routing target, not an aipass command.")
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
print()
print(f" Reach an agent: drone {command} ... · drone systems")
print(" aipass commands: aipass --help")
return 1
for stem, err in _import_failures.items():
if command in (stem, stem.replace("_", "")):
print(f"Error: '{command}' failed to load: {err}")
return 1
print(f"Unknown command: {command}")
return 1
@@ -33,8 +33,10 @@ RULES:
import importlib.util
import json
import logging
import os
import re
import shutil
import tempfile
import uuid
from datetime import date
from pathlib import Path
@@ -43,6 +45,29 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
logger = logging.getLogger(__name__)
_STALE_MANAGED_FILES: list[Path] = [
Path(".aipass") / "aipass_global_prompt.md",
]
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames.
@@ -211,8 +236,13 @@ def _claude_settings(aipass_home: str | None = None) -> str:
],
}
if aipass_home:
if aipass_home and not is_throwaway_path(aipass_home):
data["env"] = {"AIPASS_HOME": aipass_home}
elif aipass_home:
logger.warning(
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
aipass_home,
)
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
@@ -474,6 +504,7 @@ def update_project(target: Path) -> dict:
updated: list[str] = []
already_current: list[str] = []
skipped: list[str] = []
removed: list[str] = []
aipass_home: str | None = None
# Managed directories — create if missing (graceful recovery).
@@ -595,11 +626,20 @@ def update_project(target: Path) -> dict:
venv_link.symlink_to(aipass_venv)
updated.append(f".venv (symlink to AIPass runtime: {aipass_venv})")
# --- Cruft cleanup: remove known-stale AIPass-managed artifacts ---
for rel in _STALE_MANAGED_FILES:
stale_path = target / rel
if stale_path.is_file():
stale_path.unlink()
removed.append(str(stale_path))
logger.info("Removed stale managed file: %s", stale_path)
return {
"project_name": name,
"target": str(target),
"updated_files": updated,
"already_current": already_current,
"skipped_files": skipped,
"removed_files": removed,
"aipass_home": aipass_home,
}
@@ -0,0 +1,155 @@
# =================== AIPass ====================
# Name: provider_wire.py
# Description: Auto-wire provider settings from manifest into user config
# Version: 1.0.0
# Created: 2026-07-11
# Modified: 2026-07-11
# =============================================
"""provider_wire — auto-wire provider settings.
Implements the additive merge of manifest into ~/.claude/settings.json.
"""
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List
from aipass.aipass.apps.handlers.json import json_handler
# =============================================================================
# HOOK & ENV DESCRIPTIONS
# =============================================================================
HOOK_DESCRIPTIONS: Dict[str, str] = {
"pre_edit_gate.py": "blocks edits outside agent's branch",
"subagent_stop_gate.py": "validates agent output on exit",
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
"global_prompt_loader.py": "injects branch context on each turn",
"identity_injector.py": "injects agent identity on each turn",
"email_notification.py": "notifies on incoming agent mail",
"branch_prompt_loader.py": "loads branch-specific prompts",
"pre_compact.py": "saves state before context compaction",
}
ENV_DESCRIPTIONS: Dict[str, str] = {
"AIPASS_HOME": "tells agents where AIPass lives",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
}
# =============================================================================
# AUTO-WIRE
# =============================================================================
def auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
Additive merge only — never removes or overwrites existing keys/values.
Returns list of action descriptions (for logging/display).
"""
actions: List[str] = []
manifest = json_handler.load_path(manifest_path)
if manifest is None:
return actions
claude_section = manifest.get("cli", {}).get("claude", {})
if not claude_section:
return actions
settings_path = Path.home() / ".claude" / "settings.json"
if settings_path.exists():
settings = json_handler.load_path(settings_path) or {}
else:
settings = {}
if settings_path.exists():
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
shutil.copy2(settings_path, backup_path)
actions.append(f"Backed up settings to {backup_path.name}")
manifest_hooks = claude_section.get("hooks", [])
for hook in manifest_hooks:
command = hook.get("command", "")
event = hook.get("event", "")
if not command or not event:
continue
if "hooks" not in settings:
settings["hooks"] = {}
if event not in settings["hooks"]:
settings["hooks"][event] = []
event_hooks = settings["hooks"][event]
if not isinstance(event_hooks, list):
event_hooks = [event_hooks]
settings["hooks"][event] = event_hooks
hook_matcher = hook.get("matcher", "")
already_wired = any(
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
for h in event_hooks
)
if not already_wired:
cmd_entry: Dict[str, object] = {
"type": "command",
"command": command,
}
if hook.get("timeout"):
cmd_entry["timeout"] = hook["timeout"]
wrapper: Dict[str, object] = {}
if hook.get("matcher"):
wrapper["matcher"] = hook["matcher"]
wrapper["hooks"] = [cmd_entry]
event_hooks.append(wrapper)
label = command.rsplit(" ", 1)[-1] if " " in command else command
actions.append(f"Wired hook {label} -> {event}")
manifest_env = claude_section.get("env", {})
if manifest_env:
if "env" not in settings:
settings["env"] = {}
repo_root = str(manifest_path.parent.parent)
project_root = str(Path.cwd())
for key, value in manifest_env.items():
if key not in settings["env"]:
resolved = value.replace("{{REPO_ROOT}}", repo_root)
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
settings["env"][key] = resolved
actions.append(f"Set env {key}={resolved}")
manifest_perms = claude_section.get("permissions", {})
manifest_deny = manifest_perms.get("deny", [])
manifest_ask = manifest_perms.get("ask", [])
if manifest_deny or manifest_ask:
if "permissions" not in settings:
settings["permissions"] = {}
if "deny" not in settings["permissions"]:
settings["permissions"]["deny"] = []
if "ask" not in settings["permissions"]:
settings["permissions"]["ask"] = []
existing_deny = set(settings["permissions"]["deny"])
for rule in manifest_deny:
if rule not in existing_deny:
settings["permissions"]["deny"].append(rule)
actions.append(f"Added deny rule: {rule}")
existing_ask = set(settings["permissions"]["ask"])
for rule in manifest_ask:
if rule not in existing_ask:
settings["permissions"]["ask"].append(rule)
actions.append(f"Added ask rule: {rule}")
json_handler.save_path(settings_path, settings)
actions.append("Updated ~/.claude/settings.json")
json_handler.log_operation("auto_wire_provider", {"actions": len(actions)})
return actions
+135 -7
View File
@@ -17,7 +17,7 @@ import sys
from pathlib import Path
from typing import Dict, List, NamedTuple
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error as cli_error, success
from aipass.prax import logger
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
@@ -60,7 +60,8 @@ from aipass.aipass.apps.modules.doctor_fix import (
)
from aipass.aipass.apps.modules.doctor_wire import (
_auto_wire_provider,
prompt_auto_wire,
_prompt_auto_wire as prompt_auto_wire,
check_wire_verify,
reconcile_stale_deny,
)
from aipass.aipass.apps.handlers.system_detect.system_detector import (
@@ -151,6 +152,118 @@ def _check_system() -> List[CheckResult]:
return results
def _check_global_aipass_home() -> List[CheckResult]:
"""Check ~/.claude/settings.json env.AIPASS_HOME for stale or temp paths."""
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
results: List[CheckResult] = []
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
return results
try:
data = json.loads(settings_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.info("[doctor] global settings.json unreadable: %s", exc)
return results
home_val = data.get("env", {}).get("AIPASS_HOME", "")
if not home_val:
return results
home_path = Path(home_val)
if not home_path.exists():
results.append(
CheckResult(
"global AIPASS_HOME",
GLYPH_FAIL,
f"path does not exist: {home_val}",
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
)
)
elif is_throwaway_path(home_val):
results.append(
CheckResult(
"global AIPASS_HOME",
GLYPH_FAIL,
f"points to throwaway path: {home_val}",
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
)
)
else:
results.append(CheckResult("global AIPASS_HOME", GLYPH_PASS, home_val, ""))
return results
def _check_owner_seating() -> List[CheckResult]:
"""Check owner/identity health via the frozen sync-registry --check contract."""
try:
proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--check", "--json"],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
logger.info("[doctor] drone not on PATH — skipping owner seating check")
return [CheckResult("owner", GLYPH_WARN, "drone not found", "Install drone to check owner seating")]
except subprocess.TimeoutExpired:
logger.warning("[doctor] sync-registry --check timed out")
return [CheckResult("owner", GLYPH_WARN, "check timed out", "")]
stdout = proc.stdout.strip()
if not stdout:
if proc.returncode == 0:
return [CheckResult("owner", GLYPH_PASS, "clean (no details)", "")]
return [CheckResult("owner", GLYPH_WARN, "no output from check", "")]
try:
data = json.loads(stdout)
except json.JSONDecodeError:
logger.warning("[doctor] sync-registry --check returned non-JSON: %s", stdout[:200])
return [CheckResult("owner", GLYPH_WARN, "unparseable check output", "")]
issues = data.get("issues", [])
owner_name = data.get("owner")
owner_uid = data.get("owner_uid", "")
uid_short = owner_uid[:8] if owner_uid else ""
if data.get("clean", False) and not issues:
detail = f"@{owner_name} OK (seated, uid {uid_short})" if owner_name else "OK"
return [CheckResult("owner", GLYPH_PASS, detail, "")]
results: List[CheckResult] = []
for issue in issues:
flag = issue.get("flag", "unknown")
detail = issue.get("detail", flag)
results.append(CheckResult(f"owner/{flag}", GLYPH_FAIL, detail, "Run 'aipass doctor --fix'"))
if not results:
label = f"@{owner_name} ISSUES" if owner_name else "UNSEATED"
results.append(CheckResult("owner", GLYPH_FAIL, label, "Run 'aipass doctor --fix'"))
return results
def _fix_owner_seating() -> List[CheckResult]:
"""Delegate owner/identity repair to spawn's sync-registry --fix."""
try:
proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=60,
)
except FileNotFoundError:
logger.info("[doctor] drone not on PATH — skipping owner fix")
return [CheckResult("owner fix", GLYPH_WARN, "drone not found", "")]
except subprocess.TimeoutExpired:
logger.warning("[doctor] sync-registry --fix timed out")
return [CheckResult("owner fix", GLYPH_WARN, "fix timed out", "")]
if proc.returncode == 0:
return [CheckResult("owner fix", GLYPH_PASS, "registry reconciled", "")]
detail = proc.stderr.strip()[:120] if proc.stderr else "non-zero exit"
return [CheckResult("owner fix", GLYPH_FAIL, detail, "")]
def _check_identity() -> List[CheckResult]:
"""Run Identity group checks."""
results: List[CheckResult] = []
@@ -173,6 +286,8 @@ def _check_identity() -> List[CheckResult]:
)
)
results.extend(_check_global_aipass_home())
if reg_path is None:
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
return results
@@ -222,6 +337,8 @@ def _check_identity() -> List[CheckResult]:
else:
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
results.extend(_check_owner_seating())
return results
@@ -381,7 +498,7 @@ def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> Li
if fix:
actions = _auto_wire_provider(manifest_path, interactive=False)
for action in actions:
console.print(f"[green]✓[/green] {action}")
success(action)
wired = bool(actions)
else:
wired = prompt_auto_wire(manifest_path, missing_hooks, missing_env, missing_deny, missing_ask)
@@ -468,6 +585,9 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
manifest_checks = _check_provider_manifest()
results.extend(manifest_checks)
# wire_verify guard — catch empty/orphaned/duplicate provider hook entries
results.extend(CheckResult(*r) for r in check_wire_verify())
# stale rm deny rules — detect only (fix runs in run_doctor when --fix)
for tup in reconcile_stale_deny(fix=False):
results.append(CheckResult(*tup))
@@ -853,11 +973,11 @@ def run_cross_os_record(path: str | None = None, run_e2e: bool = False) -> int:
try:
written = generate_run_record(path, run_heavy_e2e=run_e2e)
except RunRecordError as exc:
console.print(f"[red]✗[/red] {exc}")
cli_error(str(exc))
logger.error("[doctor] cross-os run record failed: %s", exc)
return 1
console.print(f"[green]✓[/green] Run Record written: [bold]{written}[/bold]")
success(f"Run Record written: {written}")
console.print("[dim]Complete the '— human' rows and run the real Layer-3 acceptance pass before it counts.[/dim]")
console.print()
logger.info("[doctor] cross-os run record written to %s", written)
@@ -901,6 +1021,14 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
services = groups.get("Services", [])
groups["Services"] = [r for r in services if r.label != "rm deny migration"] + stale_results
wire_recheck = [CheckResult(*r) for r in check_wire_verify()]
services = groups.get("Services", [])
groups["Services"] = [r for r in services if r.label != "wire verify"] + wire_recheck
owner_fix = _fix_owner_seating()
identity = groups.get("Identity", [])
groups["Identity"] = [r for r in identity if not r.label.startswith("owner")] + owner_fix
pass_count = 0
warn_count = 0
error_count = 0
@@ -953,7 +1081,7 @@ def print_help() -> None:
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]")
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire + remediation report[/dim]")
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire, owner seat repair + remediation[/dim]")
console.print(" [green]aipass doctor --fix --json[/green][dim]# Remediation as JSON (for spawn)[/dim]")
console.print(" [green]aipass doctor --cross-os[/green][dim]# OS-gap + routing/version/hooks pre-flight[/dim]")
console.print(" [green]aipass doctor --cross-os --e2e[/green][dim]# …also run the heavy e2e suite[/dim]")
@@ -962,7 +1090,7 @@ def print_help() -> None:
"[dim]# write a machine pre-flight Run Record draft (human completes it)[/dim]"
)
console.print()
console.print("[yellow]OUTPUT:[/yellow] [green]✓[/green] pass [yellow]![/yellow] warn [red]✗[/red] error")
console.print("[yellow]OUTPUT:[/yellow] pass / warn / error (color-coded)")
console.print("[yellow]EXIT:[/yellow] 0 = pass/warn | 1 = errors found")
console.print()
+67 -149
View File
@@ -18,36 +18,20 @@ Provides:
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
import subprocess
import sys
from pathlib import Path
from typing import Dict, List
from typing import List, NamedTuple
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, success
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
# =============================================================================
# HOOK & ENV DESCRIPTIONS (for interactive "no" warning)
# =============================================================================
HOOK_DESCRIPTIONS: Dict[str, str] = {
"pre_edit_gate.py": "blocks edits outside agent's branch",
"subagent_stop_gate.py": "validates agent output on exit",
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
"global_prompt_loader.py": "injects branch context on each turn",
"identity_injector.py": "injects agent identity on each turn",
"email_notification.py": "notifies on incoming agent mail",
"branch_prompt_loader.py": "loads branch-specific prompts",
"pre_compact.py": "saves state before context compaction",
}
ENV_DESCRIPTIONS: Dict[str, str] = {
"AIPASS_HOME": "tells agents where AIPass lives",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
}
from aipass.aipass.apps.handlers.provider_wire import ( # noqa: F401
HOOK_DESCRIPTIONS,
ENV_DESCRIPTIONS,
auto_wire_provider as _auto_wire_provider,
)
# =============================================================================
@@ -57,130 +41,12 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
from aipass.aipass.apps.handlers.provider_reconcile import reconcile_stale_deny # noqa: E402, F401
# =============================================================================
# AUTO-WIRE
# =============================================================================
def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
Additive merge only — never removes or overwrites existing keys/values.
Returns list of action descriptions (for logging/display).
"""
actions: List[str] = []
manifest = json_handler.load_path(manifest_path)
if manifest is None:
return actions
claude_section = manifest.get("cli", {}).get("claude", {})
if not claude_section:
return actions
# Read existing settings
settings_path = Path.home() / ".claude" / "settings.json"
if settings_path.exists():
settings = json_handler.load_path(settings_path) or {}
else:
settings = {}
# Backup
if settings_path.exists():
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
shutil.copy2(settings_path, backup_path)
actions.append(f"Backed up settings to {backup_path.name}")
# Hooks — add bridge entries to provider settings
manifest_hooks = claude_section.get("hooks", [])
for hook in manifest_hooks:
command = hook.get("command", "")
event = hook.get("event", "")
if not command or not event:
continue
if "hooks" not in settings:
settings["hooks"] = {}
if event not in settings["hooks"]:
settings["hooks"][event] = []
event_hooks = settings["hooks"][event]
if not isinstance(event_hooks, list):
event_hooks = [event_hooks]
settings["hooks"][event] = event_hooks
hook_matcher = hook.get("matcher", "")
already_wired = any(
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
for h in event_hooks
)
if not already_wired:
cmd_entry: Dict[str, object] = {
"type": "command",
"command": command,
}
if hook.get("timeout"):
cmd_entry["timeout"] = hook["timeout"]
wrapper: Dict[str, object] = {}
if hook.get("matcher"):
wrapper["matcher"] = hook["matcher"]
wrapper["hooks"] = [cmd_entry]
event_hooks.append(wrapper)
label = command.rsplit(" ", 1)[-1] if " " in command else command
actions.append(f"Wired hook {label} -> {event}")
# Env vars
manifest_env = claude_section.get("env", {})
if manifest_env:
if "env" not in settings:
settings["env"] = {}
repo_root = str(manifest_path.parent.parent)
project_root = str(Path.cwd())
for key, value in manifest_env.items():
if key not in settings["env"]:
resolved = value.replace("{{REPO_ROOT}}", repo_root)
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
settings["env"][key] = resolved
actions.append(f"Set env {key}={resolved}")
# Permissions
manifest_perms = claude_section.get("permissions", {})
manifest_deny = manifest_perms.get("deny", [])
manifest_ask = manifest_perms.get("ask", [])
if manifest_deny or manifest_ask:
if "permissions" not in settings:
settings["permissions"] = {}
if "deny" not in settings["permissions"]:
settings["permissions"]["deny"] = []
if "ask" not in settings["permissions"]:
settings["permissions"]["ask"] = []
existing_deny = set(settings["permissions"]["deny"])
for rule in manifest_deny:
if rule not in existing_deny:
settings["permissions"]["deny"].append(rule)
actions.append(f"Added deny rule: {rule}")
existing_ask = set(settings["permissions"]["ask"])
for rule in manifest_ask:
if rule not in existing_ask:
settings["permissions"]["ask"].append(rule)
actions.append(f"Added ask rule: {rule}")
# Write settings back
json_handler.save_path(settings_path, settings)
actions.append("Updated ~/.claude/settings.json")
return actions
# =============================================================================
# INTERACTIVE WIRE PROMPTS
# =============================================================================
def prompt_auto_wire(
def _prompt_auto_wire(
manifest_path: Path,
missing_hooks: List[str],
missing_env: List[str],
@@ -205,16 +71,20 @@ def prompt_auto_wire(
console.print(f"\n[bold]{', '.join(parts)} missing[/bold]")
console.print("[dim]Review details: .claude/hooks/README.md[/dim]")
try:
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt) as exc:
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
if not sys.stdin.isatty():
logger.info("[doctor] non-interactive stdin — auto-wire prompt skipped, treating as decline")
answer = "n"
else:
try:
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt) as exc:
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
answer = "n"
if answer in ("y", "yes"):
actions = _auto_wire_provider(manifest_path, interactive=True)
for action in actions:
console.print(f"[green]✓[/green] {action}")
success(action)
return bool(actions)
_print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask)
@@ -304,3 +174,51 @@ def handle_command(command: str, args: list[str]) -> bool:
json_handler.log_operation("doctor_wire_noop", {"command": command})
return False
# =============================================================================
# WIRE VERIFY GUARD (doctor check row)
# =============================================================================
class WireCheckResult(NamedTuple):
"""Single doctor check result (mirrors doctor.CheckResult without importing it)."""
label: str
glyph: str
detail: str
remediation: str
_GLYPH_PASS = "[green]✓[/green]"
_GLYPH_FAIL = "[red]✗[/red]"
_GLYPH_WARN = "[yellow]![/yellow]"
def check_wire_verify() -> list[WireCheckResult]:
"""Run the hooks wire_verify guard — catch empty/orphaned/duplicate provider entries."""
try:
proc = subprocess.run(
["drone", "@hooks", "verify"],
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode == 0:
return [WireCheckResult("wire verify", _GLYPH_PASS, "provider hooks wired correctly", "")]
lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()]
detail = lines[-1] if lines else "errors detected"
return [
WireCheckResult(
"wire verify",
_GLYPH_FAIL,
detail,
"Run 'aipass doctor --fix' to re-wire, then re-run doctor to confirm",
)
]
except FileNotFoundError as exc:
logger.warning("[doctor] drone not found for wire_verify: %s", exc)
return [WireCheckResult("wire verify", _GLYPH_WARN, "drone not found", "")]
except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] wire_verify timed out: %s", exc)
return [WireCheckResult("wire verify", _GLYPH_WARN, "timed out", "")]
+2 -2
View File
@@ -21,7 +21,7 @@ Usage:
from __future__ import annotations
from aipass.cli.apps.modules import console, warning
from aipass.cli.apps.modules import console, success, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
@@ -64,7 +64,7 @@ def do_handoff(
if launched:
console.print()
console.print(f"[green]✓[/green] Session started via tmux/wt — CLI: [cyan]{cli}[/cyan]")
success(f"Session started via tmux/wt — CLI: {cli}")
console.print(f"[dim]Session name: aipass-handoff | cwd: {cwd}[/dim]")
console.print()
else:
+50 -34
View File
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
from aipass.cli.apps.modules import console, warning
from aipass.cli.apps.modules import console, error as cli_error, success, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
@@ -210,7 +210,7 @@ def _choose(msg: str, choices: List[str], default: str | None = None) -> str:
return choices[idx]
except ValueError as exc:
logger.info("[init_flow] invalid menu input %r: %s", raw, exc)
console.print("[red]Invalid choice.[/red]")
cli_error("Invalid choice.")
# --- STAGE FUNCTIONS ---
@@ -358,7 +358,7 @@ def stage_3_user_profile(
else:
profile_mod.save_profile(existing)
console.print(f"[green]✓[/green] Hello, {name}!")
success(f"Hello, {name}!")
_save_stage(3, {"name": name}, dry_run=dry_run)
return {"name": name}
@@ -379,7 +379,7 @@ def stage_4_style_questions(
else:
style = _choose("What are you looking to do?", STYLE_CHOICES, default=STYLE_CHOICES[0])
console.print(f"[green]✓[/green] Got it: {style}")
success(f"Got it: {style}")
_save_stage(4, {"style": style}, dry_run=dry_run)
return {"style": style}
@@ -424,7 +424,7 @@ def _handle_missing_claude(non_interactive: bool) -> None:
if raw.lower() in ("y", "yes", ""):
console.print("[cyan]Installing Claude Code[/cyan] [dim](this can take a minute)…[/dim]")
if _install_claude_code():
console.print("[green]✓[/green] Claude Code installed successfully.")
success("Claude Code installed successfully.")
else:
warning("[bold yellow]Installation failed.[/bold yellow]")
console.print(" Install manually: https://claude.ai/download")
@@ -460,7 +460,7 @@ def stage_5_tool_choice(
default="default",
)
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
success(f"{cli_choice} ({flag_variant})")
_save_stage(5, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
if dry_run:
@@ -496,14 +496,14 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
agent_path = f"src/{agent_name}"
console.print(f"[cyan]Creating your first agent[/cyan] [dim](drone @spawn create {agent_path})…[/dim]")
success = False
spawned = False
if dry_run:
console.print(f"[yellow]\\[dry-run][/yellow] would run: drone @spawn create {agent_path}")
success = True
spawned = True
else:
try:
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
success = proc.returncode == 0
spawned = proc.returncode == 0
except FileNotFoundError as exc:
logger.warning("[init_flow] drone not found in stage 6: %s", exc)
warning("drone not found — skipping agent creation.")
@@ -511,10 +511,10 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
logger.warning("[init_flow] spawn timed out in stage 6: %s", exc)
warning("spawn timed out — agent may still be created.")
if success:
console.print(f"[green]✓[/green] Agent created at {agent_path}")
if spawned:
success(f"Agent created at {agent_path}")
_save_stage(6, {"agent_name": agent_name, "agent_path": agent_path, "success": success}, dry_run=dry_run)
_save_stage(6, {"agent_name": agent_name, "agent_path": agent_path, "success": spawned}, dry_run=dry_run)
return {"agent_name": agent_name, "agent_path": agent_path}
@@ -576,12 +576,12 @@ def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
aipass_bin = shutil.which("aipass")
if drone_bin:
console.print(f"[green]✓[/green] drone: {drone_bin}")
success(f"drone: {drone_bin}")
else:
warning("drone not on PATH — clone the repo and run setup.sh")
if aipass_bin:
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
success(f"aipass: {aipass_bin}")
else:
warning("aipass not on PATH — clone the repo and run setup.sh")
@@ -640,7 +640,7 @@ def stage_9_handoff(
if accumulated:
_write_init_report(accumulated.get("agent_path", agent_path), accumulated, dry_run=dry_run)
console.print()
console.print("[bold green]✓ Setup complete![/bold green]")
success("Setup complete!")
console.print()
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
@@ -716,7 +716,7 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
console.print()
console.print(render_step_header(10, TOTAL_STAGES, "Done!"))
console.print()
console.print("[bold green]✓ Setup complete![/bold green]")
success("Setup complete!")
console.print()
console.print(" [cyan]aipass help[/cyan] [dim]# Ask any question[/dim]")
console.print(" [cyan]aipass doctor[/cyan] [dim]# Check system health[/dim]")
@@ -777,7 +777,7 @@ def run_init(
# Pre-flight: refuse to run inside existing projects or agent dirs
err = _preflight_check()
if err:
console.print(f"[red]✗[/red] {err}")
cli_error(str(err))
return 1
# Template selection — before scaffold
@@ -799,7 +799,7 @@ def run_init(
if not dry_run:
with activity_spinner("Building project scaffold…"):
init_project(cwd)
console.print("[green]✓[/green] Project scaffold ready")
success("Project scaffold ready")
else:
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
@@ -807,7 +807,7 @@ def run_init(
last_done = 0 if dry_run else _get_last_completed_stage()
if last_done >= TOTAL_STAGES:
console.print("[green]✓[/green] Setup already complete.")
success("Setup already complete.")
console.print("[dim]Run 'aipass doctor' to check status.[/dim]")
return 0
@@ -859,7 +859,7 @@ def run_init(
if template != TEMPLATE_AIPASS:
console.print()
console.print("[green]✓[/green] Project initialized.")
success("Project initialized.")
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
return 0
@@ -877,7 +877,7 @@ def print_introspection() -> None:
if last == 0:
console.print("[dim]Setup not started. Run: aipass init run[/dim]")
elif last >= TOTAL_STAGES:
console.print("[green]✓[/green] Setup complete.")
success("Setup complete.")
else:
console.print(f"[yellow]In progress:[/yellow] stage {last}/{TOTAL_STAGES} completed.")
console.print(f"[dim]Run 'aipass init run' to resume from stage {last + 1}.[/dim]")
@@ -911,7 +911,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
project_name = args[1] if len(args) > 1 else None
try:
result = init_project(target, project_name)
console.print(f"\n[green]✓[/green] Project initialized at [bold]{target}[/bold]")
success(f"Project initialized at {target}")
console.print()
# Find the package directory to show in guidance
@@ -938,7 +938,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
return 0
except Exception as exc:
logger.warning("[init_flow] scaffold failed: %s", exc)
console.print(f"[red]✗[/red] Init failed: {exc}")
cli_error(f"Init failed: {exc}")
return 1
@@ -952,23 +952,39 @@ def _handle_init_update(args: list[str]) -> int:
updated = result.get("updated_files", [])
current = result.get("already_current", [])
if updated:
console.print(f"[green]✓[/green] Updated {len(updated)} file(s):")
success(f"Updated {len(updated)} file(s):")
for f in updated:
console.print(f" [green]+[/green] {f}")
console.print(f" + {f}")
else:
console.print("[green]✓[/green] All files already current.")
success("All files already current.")
if current:
console.print(f" ({len(current)} already up to date)")
# Heal registry: prune stale entries (e.g. cross-project ../paths)
# Owner/identity check + heal via the frozen sync-registry contract
try:
sync_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--fix"],
check_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--check"],
capture_output=True,
text=True,
timeout=30,
)
if sync_proc.returncode == 0:
console.print(" [green]Registry synced.[/green]")
if check_proc.returncode != 0:
warning("Owner/identity issues detected — auto-repairing…")
fix_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=60,
)
if fix_proc.returncode == 0:
success("Registry owner/identity reconciled.")
else:
logger.warning("[init_flow] sync-registry --fix exit %s", fix_proc.returncode)
else:
success("Owner/identity OK.")
except FileNotFoundError:
logger.info("[init_flow] drone not on PATH — skipping owner check")
except subprocess.TimeoutExpired:
logger.warning("[init_flow] sync-registry timed out during update")
except Exception as sync_exc:
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
@@ -976,14 +992,14 @@ def _handle_init_update(args: list[str]) -> int:
return 0
except Exception as exc:
logger.warning("[init_flow] update failed: %s", exc)
console.print(f"[red]✗[/red] Update failed: {exc}")
cli_error(f"Update failed: {exc}")
return 1
def _handle_init_agent(args: list[str]) -> int:
"""Handle `aipass init agent <name>` — create a new agent via spawn."""
if not args:
console.print("[red]✗[/red] Usage: aipass init agent <name>")
cli_error("Usage: aipass init agent <name>")
return 1
agent_name = args[0]
import subprocess as _sp
@@ -1075,7 +1091,7 @@ def handle_command(command: str, args: list[str]) -> bool:
# Positional args = target path and/or project name for scaffold
err = _preflight_check()
if err:
console.print(f"[red]✗[/red] {err}")
cli_error(str(err))
sys.exit(1)
sys.exit(_handle_init_scaffold(args))
return True
+76 -13
View File
@@ -43,9 +43,10 @@ import sys
from pathlib import Path
from typing import Dict
from aipass.cli.apps.modules import console, warning
from aipass.cli.apps.modules import console, success, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.ui.progress import render_step_header
@@ -120,20 +121,26 @@ def _clone_repo(home: Path, dry_run: bool) -> bool:
return False
def _run_setup(home: Path, dry_run: bool) -> bool:
def _run_setup(home: Path, dry_run: bool, no_symlink: bool = False, force_symlink: bool = False) -> bool:
"""Run the repo's setup.sh (venv + editable install + hook wiring + binaries)."""
setup = home / "setup.sh"
# --no-init: install owns the init handoff (_handoff_to_init) — without it,
# setup.sh's own init chain (DPLAN-0234) would scaffold the project twice.
# --no-symlink / --force-symlink (#660) pass through to setup.sh's CLI-symlink guard.
setup_args = ["bash", str(setup), "--no-init"]
if no_symlink:
setup_args.append("--no-symlink")
if force_symlink:
setup_args.append("--force-symlink")
if dry_run:
console.print(f"[yellow]\\[dry-run][/yellow] would run: bash {setup}")
console.print(f"[yellow]\\[dry-run][/yellow] would run: {' '.join(setup_args)}")
return True
if not setup.is_file():
warning(f"setup.sh not found at {setup} — cannot build the environment.")
return False
console.print("[cyan]Building environment[/cyan] [dim](venv, dependencies, hook wiring)…[/dim]")
try:
# --no-init: install owns the init handoff (_handoff_to_init) — without it,
# setup.sh's own init chain (DPLAN-0234) would scaffold the project twice.
proc = subprocess.run(["bash", str(setup), "--no-init"], cwd=str(home), timeout=_SETUP_TIMEOUT)
proc = subprocess.run(setup_args, cwd=str(home), timeout=_SETUP_TIMEOUT)
if proc.returncode == 0:
return True
logger.warning("[install] setup.sh exited %s", proc.returncode)
@@ -162,11 +169,11 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
)
aipass = _resolve_aipass_bin(home)
if drone:
console.print(f"[green]✓[/green] drone: {drone}")
success(f"drone: {drone}")
else:
warning("drone not found after setup — check the setup output above.")
if aipass:
console.print(f"[green]✓[/green] aipass: {aipass}")
success(f"aipass: {aipass}")
else:
warning("aipass not found after setup — check the setup output above.")
return {"drone": drone, "aipass": aipass}
@@ -201,7 +208,7 @@ def _handoff_to_init(
headless, init is launched headless too so the whole chain stays non-blocking.
"""
console.print()
console.print(f"[bold green]✓ AIPass is installed at {home}[/bold green]")
success(f"AIPass is installed at {home}")
console.print()
console.print(" [cyan]drone systems[/cyan] [dim]# list every agent[/dim]")
console.print(" [cyan]aipass doctor[/cyan] [dim]# check system health[/dim]")
@@ -235,6 +242,39 @@ def _handoff_to_init(
warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.")
def _check_and_fix_owner(home: Path) -> None:
"""Run sync-registry --check; if issues found, auto-heal with --fix."""
try:
check_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--check"],
capture_output=True,
text=True,
timeout=30,
cwd=str(home),
)
if check_proc.returncode != 0:
warning("Owner/identity issues detected — auto-repairing…")
fix_proc = subprocess.run(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=60,
cwd=str(home),
)
if fix_proc.returncode == 0:
success("Registry owner/identity reconciled.")
else:
logger.warning("[install] sync-registry --fix exit %s", fix_proc.returncode)
else:
success("Owner/identity OK.")
except FileNotFoundError:
logger.info("[install] drone not on PATH — skipping owner check")
except subprocess.TimeoutExpired:
logger.warning("[install] sync-registry timed out during install")
except Exception as exc:
logger.warning("[install] owner check skipped: %s", exc)
def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None:
"""Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT."""
if project:
@@ -258,6 +298,8 @@ def run_install(
with_init: bool = False,
no_init: bool = False,
project: str | None = None,
no_symlink: bool = False,
force_symlink: bool = False,
) -> int:
"""Run the 4-step one-command install. Returns 0 on success, 1 on failure."""
console.print()
@@ -277,27 +319,41 @@ def run_install(
return 1
console.print(f" Home: [cyan]{home}[/cyan]")
if is_throwaway_path(home):
warning(
f"REFUSED: '{home}' is a temporary/scratchpad path. "
"Installing here would hijack the machine-wide AIPASS_HOME. "
"Use a permanent directory, or pass --force-global-home to override."
)
if "--force-global-home" not in sys.argv:
return 1
logger.warning("[install] --force-global-home override: proceeding with throwaway home %s", home)
if _looks_like_aipass_tree(home):
console.print(f"[green]✓[/green] AIPass already present at {home} — skipping download")
success(f"AIPass already present at {home} — skipping download")
elif not _clone_repo(home, dry_run):
warning("Could not fetch AIPass — aborting install.")
return 1
else:
console.print(f"[green]✓[/green] AIPass downloaded to {home}")
success(f"AIPass downloaded to {home}")
# Step 2 — build the environment via setup.sh
console.print()
console.print(render_step_header(2, TOTAL_STEPS, "Building environment"))
if not _run_setup(home, dry_run):
if not _run_setup(home, dry_run, no_symlink=no_symlink, force_symlink=force_symlink):
warning("Environment build failed — aborting install.")
return 1
console.print("[green]✓[/green] Environment ready")
success("Environment ready")
# Step 3 — verify the binaries landed
console.print()
console.print(render_step_header(3, TOTAL_STEPS, "Verifying install"))
bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"}
# Owner/identity retro-trigger — check and self-heal via spawn
if not dry_run:
_check_and_fix_owner(home)
# Step 4 — hand off into init (or print next steps)
console.print()
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
@@ -323,7 +379,10 @@ def print_help() -> None:
console.print(" [green]aipass install --here[/green] [dim]# install into current dir[/dim]")
console.print(" [green]aipass install --no-init[/green] [dim]# install only, skip init[/dim]")
console.print(" [green]aipass install --with-init[/green] [dim]# force init even when headless[/dim]")
console.print(" [green]aipass install --no-symlink[/green] [dim]# skip global CLI symlinks[/dim]")
console.print(" [green]aipass install --force-symlink[/green] [dim]# repoint from another install[/dim]")
console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]")
console.print(" [green]aipass install --force-global-home[/green] [dim]# allow install into /tmp (unsafe)[/dim]")
console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]")
console.print()
console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init")
@@ -368,6 +427,8 @@ def handle_command(command: str, args: list[str]) -> bool:
here = "--here" in run_args
with_init = "--with-init" in run_args
no_init = "--no-init" in run_args
no_symlink = "--no-symlink" in run_args
force_symlink = "--force-symlink" in run_args
path = _flag_value("--path")
project = _flag_value("--project")
@@ -379,6 +440,8 @@ def handle_command(command: str, args: list[str]) -> bool:
with_init=with_init,
no_init=no_init,
project=project,
no_symlink=no_symlink,
force_symlink=force_symlink,
)
json_handler.log_operation(
"install_run",
+5 -5
View File
@@ -24,7 +24,7 @@ import os
import tempfile
from pathlib import Path
from aipass.cli.apps.modules import console, error, warning
from aipass.cli.apps.modules import console, error, success, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
@@ -151,13 +151,13 @@ def handle_command(command: str, args: list[str]) -> bool:
return True
field, value = args[1], args[2]
if field not in USER_FIELDS:
console.print(f"[red]Unknown field: {field}[/red]")
error(f"Unknown field: {field}")
console.print("[dim]Valid fields: " + ", ".join(USER_FIELDS) + "[/dim]")
return True
profile = get_user_profile()
profile[field] = value
save_profile(profile)
console.print(f"[green]✓[/green] {field} = {value}")
success(f"{field} = {value}")
return True
if args[0] == "clear":
@@ -166,7 +166,7 @@ def handle_command(command: str, args: list[str]) -> bool:
skip_confirm = any(a in ("--yes", "-y") for a in args[1:])
if skip_confirm:
save_profile({f: None for f in USER_FIELDS})
console.print("[green]✓[/green] Profile cleared.")
success("Profile cleared.")
return True
warning("Type 'aipass' to confirm clearing your profile (ctrl-C to cancel):")
try:
@@ -177,7 +177,7 @@ def handle_command(command: str, args: list[str]) -> bool:
return True
if confirm == "aipass":
save_profile({f: None for f in USER_FIELDS})
console.print("[green]✓[/green] Profile cleared.")
success("Profile cleared.")
else:
console.print("[yellow]Cancelled.[/yellow]")
return True
+42
View File
@@ -0,0 +1,42 @@
# Probe Hygiene SOP
Standard operating procedure for throwaway test installs of AIPass.
## Principle
Temporary environments are used, then deleted, gone. Nothing permanent may ever point at a temp path.
## Rules
- Install probes and throwaway test installs live ONLY in throwaway directories (system temp dir, `/tmp`, Claude Code scratchpad dirs).
- Used = deleted = GONE. Delete the probe directory immediately after the test completes.
- NOTHING permanent may ever point at a temporary path: no global settings (`~/.claude/settings.json` `env.AIPASS_HOME`), no symlinks, no registry entries.
- `aipass install` now refuses throwaway homes automatically. The `--force-global-home` flag is the explicit unsafe override, for probe use only.
- `aipass doctor` now detects a hijacked global `AIPASS_HOME` (nonexistent or temp path) and flags it as an error with fix guidance.
## What the defenses do
1. **`is_throwaway_path()`** (bootstrap.py) — detects paths under `tempfile.gettempdir()`, `/tmp` (POSIX), or containing `scratchpad`. Shared gate used by both install and bootstrap.
2. **`run_install()` gate** (install.py) — refuses to proceed when the resolved home is throwaway. Prints a loud `REFUSED` message with guidance. `--force-global-home` overrides.
3. **`_claude_settings()` gate** (bootstrap.py) — refuses to write `env.AIPASS_HOME` into project settings when the detected home is throwaway. Defense-in-depth behind the install gate.
4. **`_check_global_aipass_home()`** (doctor.py) — reads `~/.claude/settings.json` and flags `env.AIPASS_HOME` pointing at a nonexistent or throwaway path as an error.
## Correct probe workflow
```bash
# 1. Create throwaway dir
cd /tmp && mkdir aipass_probe && cd aipass_probe
# 2. Run the probe (install will refuse — this is correct)
aipass install --here
# → REFUSED: '/tmp/aipass_probe' is a temporary/scratchpad path.
# 3. If you genuinely need a temp install (testing only):
aipass install --here --force-global-home
# 4. IMMEDIATELY after testing, delete the probe
rm -rf /tmp/aipass_probe
# 5. Verify global settings are clean
aipass doctor
```
+128 -17
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import importlib.metadata
import types
from unittest.mock import MagicMock, patch
@@ -129,23 +130,15 @@ class TestRouteCommand:
mod2.handle_command.assert_called_once_with("cmd", ["arg1"])
mod3.handle_command.assert_not_called()
def test_handles_module_exception(self) -> None:
"""Exception in a module is caught; returns False if no other handles."""
def test_module_exception_re_raises(self) -> None:
"""Exception in a handler is re-raised so callers see the real error."""
mod = MagicMock()
mod.handle_command.side_effect = RuntimeError("crash")
mod.__name__ = "broken_mod"
assert route_command("cmd", [], [mod]) is False
import pytest
def test_exception_in_first_tries_second(self) -> None:
"""Exception in first module does not prevent second from handling."""
mod1 = MagicMock()
mod1.handle_command.side_effect = RuntimeError("crash")
mod1.__name__ = "mod1"
mod2 = MagicMock()
mod2.handle_command.return_value = True
assert route_command("cmd", [], [mod1, mod2]) is True
mod2.handle_command.assert_called_once()
with pytest.raises(RuntimeError, match="crash"):
route_command("cmd", [], [mod])
# =============================================================================
@@ -157,22 +150,39 @@ class TestMain:
"""Tests for the main() entry point."""
def test_version_flag(self) -> None:
"""--version prints version and returns 0."""
"""--version prints real package version and returns 0."""
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch("builtins.print") as mock_print:
result = main()
assert result == 0
mock_print.assert_called_once_with("aipass 0.1.0")
printed = mock_print.call_args[0][0]
assert printed.startswith("aipass ")
assert printed != "aipass 0.1.0"
def test_version_flag_short(self) -> None:
"""-V prints version and returns 0."""
"""-V prints real package version and returns 0."""
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch("builtins.print") as mock_print:
result = main()
assert result == 0
mock_print.assert_called_once_with("aipass 0.1.0")
printed = mock_print.call_args[0][0]
assert printed.startswith("aipass ")
def test_version_flag_fallback(self) -> None:
"""--version prints 'unknown' when package metadata unavailable."""
_not_found = importlib.metadata.PackageNotFoundError
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch(
"aipass.aipass.apps.aipass.importlib.metadata.version",
side_effect=_not_found,
):
with patch("builtins.print") as mock_print:
result = main()
assert result == 0
mock_print.assert_called_once_with("aipass unknown")
def test_help_flag_shows_help(self) -> None:
"""--help shows module list and returns 0."""
@@ -252,6 +262,38 @@ class TestMain:
assert result == 0
mod.handle_command.assert_called_once_with("doctor", [])
def test_at_prefix_shows_drone_guidance(self) -> None:
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "@drone" in printed
assert "drone routing target" in printed
assert "Unknown command" not in printed
def test_at_prefix_uses_actual_name(self) -> None:
"""@memory prints guidance with the actual @name the user typed."""
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "@memory" in printed
assert "drone @memory" in printed
def test_plain_bad_command_still_unknown(self) -> None:
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
mock_print.assert_called_with("Unknown command: frobnicate")
def test_command_with_remaining_args(self) -> None:
"""Remaining args are passed to route_command."""
mod = MagicMock()
@@ -262,3 +304,72 @@ class TestMain:
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
main()
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
def test_help_shows_command_constant(self) -> None:
"""Help listing uses module COMMAND constant, not file stem."""
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
mod.__doc__ = "Help chatbot"
mod.COMMAND = "help" # type: ignore[attr-defined]
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
with patch(
"aipass.aipass.apps.aipass.discover_modules",
return_value=[mod],
):
with patch("builtins.print") as mock_print:
main()
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "help" in printed
assert "help_chat" not in printed
def test_help_falls_back_to_stem(self) -> None:
"""Without COMMAND constant, help listing uses file stem."""
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
mod.__doc__ = "Doctor module"
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
with patch(
"aipass.aipass.apps.aipass.discover_modules",
return_value=[mod],
):
with patch("builtins.print") as mock_print:
main()
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "doctor" in printed
def test_handler_crash_surfaces_error(self) -> None:
"""Handler crash prints real error, not 'Unknown command'."""
mod = MagicMock()
mod.handle_command.side_effect = RuntimeError("db connection failed")
mod.__name__ = "aipass.aipass.apps.modules.doctor"
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "doctor"]):
with patch(
"aipass.aipass.apps.aipass.discover_modules",
return_value=[mod],
):
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "db connection failed" in printed
assert "Unknown command" not in printed
def test_import_failure_surfaces_on_command(self) -> None:
"""Failed module import surfaces when user types that command."""
import aipass.aipass.apps.aipass as aipass_mod
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "broken"]):
with patch(
"aipass.aipass.apps.aipass.discover_modules",
return_value=[],
):
aipass_mod._import_failures.clear()
aipass_mod._import_failures["broken"] = ImportError("no module")
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
assert "failed to load" in printed
assert "no module" in printed
assert "Unknown command" not in printed
aipass_mod._import_failures.clear()
+171 -5
View File
@@ -24,6 +24,7 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json,
_sanitize_name,
is_throwaway_path,
init_project,
update_project,
)
@@ -277,8 +278,12 @@ def test_init_project_claude_settings_content(tmp_path):
assert "deny" in data["permissions"]
def test_init_project_settings_no_hooks(tmp_path):
def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
""".claude/settings.json has no hooks — all hooks fire from provider level."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
)
target = tmp_path / "proj"
target.mkdir()
@@ -440,6 +445,7 @@ def test_update_project_return_dict_structure(tmp_path):
"updated_files",
"already_current",
"skipped_files",
"removed_files",
"aipass_home",
}
assert result["project_name"] == "UPD"
@@ -449,8 +455,12 @@ def test_update_project_return_dict_structure(tmp_path):
assert isinstance(result["skipped_files"], list)
def test_update_project_already_current_after_init(tmp_path):
def test_update_project_already_current_after_init(tmp_path, monkeypatch):
"""Running update immediately after init reports all managed files as already current."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
)
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="fresh")
@@ -461,8 +471,12 @@ def test_update_project_already_current_after_init(tmp_path):
assert len(result["already_current"]) >= 5
def test_update_project_idempotent(tmp_path):
def test_update_project_idempotent(tmp_path, monkeypatch):
"""Running update twice in a row produces no changes on second run."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
)
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="idem")
@@ -569,8 +583,12 @@ def test_init_project_returns_aipass_home(tmp_path):
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
def test_init_project_settings_has_aipass_home_when_detected(tmp_path):
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
)
target = tmp_path / "proj"
target.mkdir()
@@ -596,8 +614,12 @@ def test_update_project_returns_aipass_home(tmp_path):
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
def test_update_project_adds_aipass_home_if_missing(tmp_path):
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
)
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="addenv")
@@ -1097,3 +1119,147 @@ def test_with_source_header_is_first_line():
lines = result.split("\n")
assert lines[0] == "<!-- Source: /test.md -->"
assert lines[1] == "content"
# ---------------------------------------------------------------------------
# GAP 1: AGENTS.md sync on update (#676)
# ---------------------------------------------------------------------------
def test_update_project_syncs_agents_md(tmp_path):
"""update restores AGENTS.md when its content has been altered."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="sync")
agents_md = target / "AGENTS.md"
agents_md.write_text("# Corrupted\n", encoding="utf-8")
result = update_project(target)
assert str(agents_md.resolve()) in result["updated_files"]
restored = agents_md.read_text(encoding="utf-8")
assert "# SYNC" in restored
assert "Startup protocol" in restored
def test_update_project_creates_missing_agents_md(tmp_path):
"""update creates AGENTS.md if it was deleted from the project."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="miss")
agents_md = target / "AGENTS.md"
agents_md.unlink()
result = update_project(target)
assert agents_md.exists()
assert str(agents_md.resolve()) in result["updated_files"]
content = agents_md.read_text(encoding="utf-8")
assert "# MISS" in content
def test_update_project_agents_md_already_current(tmp_path):
"""update reports AGENTS.md as already_current when unchanged."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="cur")
result = update_project(target)
assert any("AGENTS.md" in f for f in result["already_current"])
assert not any("AGENTS.md" in f for f in result["updated_files"])
# ---------------------------------------------------------------------------
# GAP 2: Cruft cleanup on update (#676)
# ---------------------------------------------------------------------------
def test_update_project_removes_stale_global_prompt(tmp_path):
"""update removes retired .aipass/aipass_global_prompt.md."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="cruft")
stale = target / ".aipass" / "aipass_global_prompt.md"
stale.write_text("# old\n", encoding="utf-8")
result = update_project(target)
assert not stale.exists()
assert str(stale) in result["removed_files"]
def test_update_project_cleanup_does_not_touch_user_files(tmp_path):
"""Cruft cleanup never removes user-owned files."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="safe")
readme = target / "README.md"
registry = target / "SAFE_REGISTRY.json"
result = update_project(target)
assert readme.exists()
assert registry.exists()
assert len(result["removed_files"]) == 0
def test_update_project_removed_files_in_result(tmp_path):
"""Return dict always contains the removed_files key."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="rkey")
result = update_project(target)
assert "removed_files" in result
assert isinstance(result["removed_files"], list)
def test_update_project_cleanup_no_stale_is_noop(tmp_path):
"""When no stale files exist, removed_files is empty."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="clean")
result = update_project(target)
assert result["removed_files"] == []
# ---------------------------------------------------------------------------
# is_throwaway_path tests
# ---------------------------------------------------------------------------
def test_throwaway_path_detects_tmp(tmp_path):
"""Paths under the system temp dir are throwaway."""
assert is_throwaway_path(str(tmp_path))
def test_throwaway_path_detects_scratchpad():
"""Paths containing 'scratchpad' are throwaway."""
assert is_throwaway_path(str(Path.home() / ".claude" / "scratchpad" / "probe_1"))
def test_throwaway_path_allows_normal():
"""Normal home-directory paths are not throwaway."""
assert not is_throwaway_path(str(Path.home() / "AIPass"))
def test_throwaway_path_allows_project():
"""A typical project path is not throwaway."""
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
def test_settings_omits_throwaway_aipass_home(tmp_path):
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
content = _claude_settings(str(tmp_path))
data = json.loads(content)
assert "AIPASS_HOME" not in data.get("env", {})
+347 -8
View File
@@ -658,7 +658,7 @@ class TestReconcileStaleDeny:
"""Missing settings.json returns no results."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert results == []
@@ -672,7 +672,7 @@ class TestReconcileStaleDeny:
json.dumps({"permissions": {"deny": ["Bash(git push --force*)", "Bash(git reset --hard*)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
@@ -688,7 +688,7 @@ class TestReconcileStaleDeny:
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_WARN
@@ -706,7 +706,7 @@ class TestReconcileStaleDeny:
"env": {"AIPASS_HOME": "/test"},
}
settings.write_text(json.dumps(original), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
@@ -727,7 +727,7 @@ class TestReconcileStaleDeny:
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git reset --hard*)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
@@ -744,7 +744,7 @@ class TestReconcileStaleDeny:
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(rm -r *)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
reconcile_stale_deny(fix=True)
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
@@ -758,7 +758,7 @@ class TestReconcileStaleDeny:
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"permissions": {"deny": []}}), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
@@ -770,7 +770,346 @@ class TestReconcileStaleDeny:
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"env": {"FOO": "bar"}}), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
class TestCheckWireVerify:
"""Tests for check_wire_verify() — hooks wire_verify guard."""
def test_pass_on_zero_exit(self) -> None:
"""Exit 0 from drone @hooks verify produces a PASS row."""
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
results = check_wire_verify()
assert len(results) == 1
assert results[0].label == "wire verify"
assert results[0].glyph == "[green]✓[/green]"
def test_fail_on_nonzero_exit(self) -> None:
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
results = check_wire_verify()
assert len(results) == 1
assert results[0].glyph == "[red]✗[/red]"
assert "errors" in results[0].detail
def test_warn_on_drone_not_found(self) -> None:
"""FileNotFoundError (drone missing) produces a WARN row."""
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
with patch(
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
side_effect=FileNotFoundError("drone"),
):
results = check_wire_verify()
assert len(results) == 1
assert results[0].glyph == "[yellow]![/yellow]"
def test_warn_on_timeout(self) -> None:
"""TimeoutExpired produces a WARN row."""
import subprocess as sp
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
with patch(
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
):
results = check_wire_verify()
assert len(results) == 1
assert results[0].glyph == "[yellow]![/yellow]"
assert "timed out" in results[0].detail
# =============================================================================
# prompt_auto_wire — non-interactive stdin guard (issue #663)
# =============================================================================
class TestPromptAutoWireIsatty:
"""Guard: non-tty stdin must not block on input() (#663)."""
@staticmethod
def _args() -> dict:
return {
"manifest_path": MagicMock(),
"missing_hooks": ["some_hook"],
"missing_env": [],
"missing_deny": [],
"missing_ask": [],
}
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
from aipass.aipass.apps.modules import doctor_wire
with (
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
patch("builtins.input") as mock_input,
patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn,
):
mock_stdin.isatty.return_value = False
result = doctor_wire._prompt_auto_wire(**self._args())
assert result is False
mock_input.assert_not_called()
mock_warn.assert_called_once()
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
"""Tty stdin still prompts; a 'n' answer declines."""
from aipass.aipass.apps.modules import doctor_wire
with (
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
patch("builtins.input", return_value="n") as mock_input,
patch.object(doctor_wire, "_print_manual_wire_warning"),
):
mock_stdin.isatty.return_value = True
result = doctor_wire._prompt_auto_wire(**self._args())
assert result is False
mock_input.assert_called_once()
def test_tty_stdin_accepts_and_wires(self) -> None:
"""Tty stdin with a 'y' answer runs the wire and returns True."""
from aipass.aipass.apps.modules import doctor_wire
with (
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
patch("builtins.input", return_value="y"),
patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
):
mock_stdin.isatty.return_value = True
result = doctor_wire._prompt_auto_wire(**self._args())
assert result is True
mock_wire.assert_called_once()
# ---------------------------------------------------------------------------
# _check_global_aipass_home tests (#688)
# ---------------------------------------------------------------------------
class TestCheckGlobalAipassHome:
"""Tests for _check_global_aipass_home doctor check."""
def test_nonexistent_path_is_error(self, tmp_path):
"""AIPASS_HOME pointing to a nonexistent path is flagged as error."""
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"env": {"AIPASS_HOME": str(tmp_path / "gone")}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
results = _check_global_aipass_home()
fails = [r for r in results if "does not exist" in r.detail]
assert len(fails) == 1
def test_throwaway_path_is_error(self, tmp_path):
"""AIPASS_HOME pointing to a temp path is flagged as error."""
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"env": {"AIPASS_HOME": str(tmp_path)}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
results = _check_global_aipass_home()
fails = [r for r in results if "throwaway" in r.detail]
assert len(fails) == 1
def test_valid_path_passes(self, tmp_path):
"""AIPASS_HOME pointing to a real, non-temp path passes."""
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home, GLYPH_PASS
real_home = tmp_path / "AIPass"
real_home.mkdir()
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"env": {"AIPASS_HOME": str(real_home)}}),
encoding="utf-8",
)
with (
patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path),
patch(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
return_value=False,
),
):
results = _check_global_aipass_home()
assert any(r.glyph == GLYPH_PASS for r in results)
def test_no_settings_file_is_noop(self, tmp_path):
"""Missing ~/.claude/settings.json produces no results."""
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
results = _check_global_aipass_home()
assert results == []
# ---------------------------------------------------------------------------
# _check_owner_seating / _fix_owner_seating tests (DPLAN-0239 P3+P5)
# ---------------------------------------------------------------------------
class TestCheckOwnerSeating:
"""Tests for owner/identity detection via sync-registry --check."""
def test_clean_owner_returns_pass(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
check_json = json.dumps({"clean": True, "owner": "vera", "owner_uid": "8fb38c96-abcd", "issues": []})
mock_proc = MagicMock(returncode=0, stdout=check_json, stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_PASS
assert "@vera" in results[0].detail
assert "8fb38c96" in results[0].detail
def test_unseated_owner_returns_errors(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
check_json = json.dumps(
{
"clean": False,
"owner": None,
"owner_uid": "",
"issues": [
{"flag": "no_owner", "detail": "No owner:true in registry"},
{"flag": "metadata_id_missing", "detail": "metadata.id absent"},
],
}
)
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _check_owner_seating()
assert len(results) == 2
assert all(r.glyph == GLYPH_FAIL for r in results)
assert results[0].label == "owner/no_owner"
def test_issue_with_branch_field(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
check_json = json.dumps(
{
"clean": False,
"owner": "vera",
"owner_uid": "8fb38c96",
"issues": [
{"flag": "entry_rid_stale", "detail": "stale rid", "branch": "vera"},
],
}
)
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_FAIL
assert results[0].label == "owner/entry_rid_stale"
def test_drone_not_found_returns_warn(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
with patch(
"aipass.aipass.apps.modules.doctor.subprocess.run",
side_effect=FileNotFoundError("drone"),
):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_WARN
assert "drone" in results[0].detail
def test_timeout_returns_warn(self):
import subprocess as _sp
from aipass.aipass.apps.modules.doctor import _check_owner_seating
with patch(
"aipass.aipass.apps.modules.doctor.subprocess.run",
side_effect=_sp.TimeoutExpired("drone", 30),
):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_WARN
def test_non_json_output_returns_warn(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
mock_proc = MagicMock(returncode=1, stdout="not json at all", stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_WARN
def test_empty_stdout_exit_zero(self):
from aipass.aipass.apps.modules.doctor import _check_owner_seating
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _check_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_PASS
class TestFixOwnerSeating:
"""Tests for owner/identity repair via sync-registry --fix."""
def test_fix_success_returns_pass(self):
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _fix_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_PASS
assert "reconciled" in results[0].detail
def test_fix_failure_returns_fail(self):
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
mock_proc = MagicMock(returncode=1, stdout="", stderr="owner conflict")
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
results = _fix_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_FAIL
def test_fix_drone_not_found(self):
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
with patch(
"aipass.aipass.apps.modules.doctor.subprocess.run",
side_effect=FileNotFoundError("drone"),
):
results = _fix_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_WARN
def test_fix_timeout(self):
import subprocess as _sp
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
with patch(
"aipass.aipass.apps.modules.doctor.subprocess.run",
side_effect=_sp.TimeoutExpired("drone", 60),
):
results = _fix_owner_seating()
assert len(results) == 1
assert results[0].glyph == GLYPH_WARN
+54 -24
View File
@@ -261,6 +261,11 @@ def _bypass_preflight():
class TestRunInit:
@pytest.fixture(autouse=True)
def _isolate_cwd(self, tmp_path, monkeypatch):
"""Avoid _guard_init rejecting the real cwd when it has .trinity/."""
monkeypatch.chdir(tmp_path)
def _patch_all_stages(self):
"""Context manager that patches all 10 stage functions to no-ops."""
stage_names = [
@@ -655,47 +660,74 @@ _MOD_UPDATE = "aipass.aipass.apps.modules.init_flow"
class TestInitUpdateRegistrySync:
"""Tests for registry sync subprocess call in _handle_init_update."""
"""Tests for owner/identity check+fix in _handle_init_update (DPLAN-0239 P5)."""
def test_sync_success_prints_message(self, tmp_path: Path) -> None:
"""Successful drone sync-registry prints 'Registry synced.'"""
mock_result = MagicMock(returncode=0)
def test_clean_check_prints_ok(self, tmp_path: Path) -> None:
"""Clean --check (exit 0) prints 'Owner/identity OK.' and skips --fix."""
check_proc = MagicMock(returncode=0, stdout="", stderr="")
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run,
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=check_proc) as mock_run,
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.success") as mock_success,
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
mock_run.assert_called_once_with(
["drone", "@spawn", "sync-registry", "--fix"],
capture_output=True,
text=True,
timeout=30,
)
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 1
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "--check" in args
ok_calls = [c for c in mock_success.call_args_list if "Owner/identity OK" in str(c)]
assert len(ok_calls) == 1
def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None:
"""Non-zero exit from drone sync-registry is silently skipped."""
mock_result = MagicMock(returncode=1)
def test_issues_trigger_fix(self, tmp_path: Path) -> None:
"""Non-zero --check triggers --fix; success prints reconciled."""
check_proc = MagicMock(returncode=1, stdout="", stderr="")
fix_proc = MagicMock(returncode=0, stdout="", stderr="")
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result),
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(
f"{_MOD_UPDATE}.subprocess.run",
side_effect=[check_proc, fix_proc],
) as mock_run,
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.success") as mock_success,
patch(f"{_MOD_UPDATE}.warning"),
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
assert mock_run.call_count == 2
fix_args = mock_run.call_args_list[1][0][0]
assert "--fix" in fix_args
reconciled = [c for c in mock_success.call_args_list if "reconciled" in str(c)]
assert len(reconciled) == 1
def test_fix_failure_degrades_silently(self, tmp_path: Path) -> None:
"""Non-zero --fix exit degrades gracefully (no crash)."""
check_proc = MagicMock(returncode=1, stdout="", stderr="")
fix_proc = MagicMock(returncode=1, stdout="", stderr="")
with (
patch(
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
patch(
f"{_MOD_UPDATE}.subprocess.run",
side_effect=[check_proc, fix_proc],
),
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.warning"),
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 0
def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
"""FileNotFoundError (no drone binary) degrades gracefully."""
@@ -705,13 +737,11 @@ class TestInitUpdateRegistrySync:
return_value={"updated_files": [], "already_current": []},
),
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
patch(f"{_MOD_UPDATE}.console") as mock_console,
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.json_handler"),
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
assert len(sync_calls) == 0
def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
"""subprocess.TimeoutExpired degrades gracefully."""
+135 -1
View File
@@ -146,6 +146,32 @@ class TestRunSetup:
assert _run_setup(tmp_path, dry_run=False) is True
run.assert_called_once()
def test_no_symlink_flag_forwarded(self, tmp_path: Path) -> None:
"""--no-symlink passes through to setup.sh (#660)."""
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
assert _run_setup(tmp_path, dry_run=False, no_symlink=True) is True
argv = run.call_args[0][0]
assert "--no-symlink" in argv
assert "--force-symlink" not in argv
def test_force_symlink_flag_forwarded(self, tmp_path: Path) -> None:
"""--force-symlink passes through to setup.sh (#660)."""
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
assert _run_setup(tmp_path, dry_run=False, force_symlink=True) is True
argv = run.call_args[0][0]
assert "--force-symlink" in argv
def test_symlink_flags_absent_by_default(self, tmp_path: Path) -> None:
"""No symlink flags forwarded unless requested (#660)."""
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
assert _run_setup(tmp_path, dry_run=False) is True
argv = run.call_args[0][0]
assert "--no-symlink" not in argv
assert "--force-symlink" not in argv
class TestRunInstall:
"""The four-step orchestrator."""
@@ -170,13 +196,15 @@ class TestRunInstall:
setup.assert_not_called()
def test_full_happy_path(self, tmp_path: Path) -> None:
"""Clone + setup + verify + next-steps returns success."""
"""Clone + setup + verify + owner check + next-steps returns success."""
home = tmp_path / "AIPass"
with (
patch(f"{_MOD}._resolve_home", return_value=home),
patch(f"{_MOD}.is_throwaway_path", return_value=False),
patch(f"{_MOD}._clone_repo", return_value=True),
patch(f"{_MOD}._run_setup", return_value=True),
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
patch(f"{_MOD}._check_and_fix_owner"),
patch(f"{_MOD}._handoff_to_init") as nxt,
):
rc = run_install(non_interactive=True, dry_run=False)
@@ -313,3 +341,109 @@ class TestSmoke:
def test_total_steps_constant(self) -> None:
"""The install flow advertises four steps."""
assert TOTAL_STEPS == 4
# ---------------------------------------------------------------------------
# Throwaway-path gate (#688)
# ---------------------------------------------------------------------------
class TestThrowawayGate:
"""Install refuses throwaway homes unless --force-global-home."""
def test_refuses_tmp_home(self, tmp_path) -> None:
"""run_install returns 1 when home resolves to a temp path."""
with (
patch(
"aipass.aipass.apps.modules.install._resolve_home",
return_value=tmp_path,
),
patch("aipass.aipass.apps.modules.install.sys.argv", ["aipass", "install"]),
):
result = run_install(non_interactive=True, no_init=True)
assert result == 1
def test_force_flag_overrides(self, tmp_path) -> None:
"""--force-global-home lets a temp home proceed past the gate."""
with (
patch(
"aipass.aipass.apps.modules.install._resolve_home",
return_value=tmp_path,
),
patch(
"aipass.aipass.apps.modules.install.sys.argv",
["aipass", "install", "--force-global-home"],
),
patch(
"aipass.aipass.apps.modules.install._looks_like_aipass_tree",
return_value=True,
),
patch(
"aipass.aipass.apps.modules.install._run_setup",
return_value=True,
),
patch(
"aipass.aipass.apps.modules.install._verify_binaries",
return_value={"drone": "x", "aipass": "x"},
),
patch("aipass.aipass.apps.modules.install._handoff_to_init"),
patch("aipass.aipass.apps.modules.install._check_and_fix_owner"),
):
result = run_install(non_interactive=True, no_init=True)
assert result == 0
# ---------------------------------------------------------------------------
# _check_and_fix_owner tests (DPLAN-0239 P5)
# ---------------------------------------------------------------------------
class TestCheckAndFixOwner:
"""Tests for install-time owner/identity check+fix retro-trigger."""
def test_clean_check_skips_fix(self, tmp_path) -> None:
from aipass.aipass.apps.modules.install import _check_and_fix_owner
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
with patch(
"aipass.aipass.apps.modules.install.subprocess.run",
return_value=mock_proc,
) as mock_run:
_check_and_fix_owner(tmp_path)
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "--check" in args
def test_issues_trigger_fix(self, tmp_path) -> None:
from aipass.aipass.apps.modules.install import _check_and_fix_owner
check_proc = MagicMock(returncode=1, stdout="", stderr="")
fix_proc = MagicMock(returncode=0, stdout="", stderr="")
with patch(
"aipass.aipass.apps.modules.install.subprocess.run",
side_effect=[check_proc, fix_proc],
) as mock_run:
_check_and_fix_owner(tmp_path)
assert mock_run.call_count == 2
fix_args = mock_run.call_args_list[1][0][0]
assert "--fix" in fix_args
def test_drone_not_found_is_silent(self, tmp_path) -> None:
from aipass.aipass.apps.modules.install import _check_and_fix_owner
with patch(
"aipass.aipass.apps.modules.install.subprocess.run",
side_effect=FileNotFoundError("drone"),
):
_check_and_fix_owner(tmp_path)
def test_timeout_is_silent(self, tmp_path) -> None:
import subprocess as _sp
from aipass.aipass.apps.modules.install import _check_and_fix_owner
with patch(
"aipass.aipass.apps.modules.install.subprocess.run",
side_effect=_sp.TimeoutExpired("drone", 30),
):
_check_and_fix_owner(tmp_path)
+13 -5
View File
@@ -149,11 +149,11 @@ def print_help():
console.print("[bold cyan]WHAT IS API?[/bold cyan]")
console.print()
console.print("API Branch provides:")
console.print(" [green]✓[/green] OpenRouter API client integration")
console.print(" [green]✓[/green] API key management and validation")
console.print(" [green]✓[/green] Model discovery and availability")
console.print(" [green]✓[/green] Usage tracking and statistics")
console.print(" [green]✓[/green] Connection testing and diagnostics")
console.print(" [cyan]•[/cyan] OpenRouter API client integration")
console.print(" [cyan]•[/cyan] API key management and validation")
console.print(" [cyan]•[/cyan] Model discovery and availability")
console.print(" [cyan]•[/cyan] Usage tracking and statistics")
console.print(" [cyan]•[/cyan] Connection testing and diagnostics")
console.print()
console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]")
@@ -287,6 +287,14 @@ def main():
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Subcommand --help guard
if remaining_args and remaining_args[0] in ["--help", "-h"]:
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print_help()
return 0
# Log api command attempt
json_handler.log_operation("api_command_attempted", {"command": command, "modules_discovered": len(modules)})
+1 -5
View File
@@ -323,9 +323,5 @@ if __name__ == "__main__":
if handle_command(command, remaining_args):
sys.exit(0)
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]drone @api --help[/dim] for available commands")
console.print()
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
sys.exit(1)
+1 -5
View File
@@ -357,9 +357,5 @@ if __name__ == "__main__":
if handle_command(command, remaining_args):
sys.exit(0)
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]drone @api --help[/dim] for available commands")
console.print()
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
sys.exit(1)
@@ -29,7 +29,7 @@ if sys.platform == "win32":
from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, success, error
from aipass.cli.apps.modules import console, header, success, error, warning
from aipass.api.apps.handlers.json import json_handler
from aipass.api.apps.handlers.auth import keys
from aipass.api.apps.handlers.openrouter import client, models
@@ -286,7 +286,7 @@ def check_status():
console.print(" [cyan]Key configured:[/cyan] [green]yes[/green]")
console.print(f" [cyan]Key:[/cyan] {masked}")
else:
console.print(" [cyan]Key configured:[/cyan] [red]no[/red]")
warning("API key not configured")
diagnosis = keys.diagnose_key("openrouter")
console.print(f" [cyan]Reason:[/cyan] {diagnosis}")
@@ -300,7 +300,7 @@ def check_status():
console.print(" [cyan]OpenAI SDK:[/cyan] [green]available[/green]")
except ImportError:
logger.warning("OpenAI SDK not installed")
console.print(" [cyan]OpenAI SDK:[/cyan] [red]missing[/red]")
warning("OpenAI SDK not installed")
# Client cache stats
cache_stats = client.get_cache_stats()
@@ -365,9 +365,5 @@ if __name__ == "__main__":
if handle_command(command, remaining_args):
sys.exit(0)
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]drone @api --help[/dim] for available commands")
console.print()
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
sys.exit(1)
+2 -4
View File
@@ -32,7 +32,7 @@ if sys.platform == "win32":
from typing import Any, List, Optional, Union
from aipass.prax import logger # noqa: F401 — seedgo imports standard
from aipass.cli.apps.modules import console, header
from aipass.cli.apps.modules import console, header, error
from aipass.api.apps.handlers.json import json_handler
from aipass.api.apps.handlers.auth import secrets as _handler
@@ -156,7 +156,5 @@ if __name__ == "__main__":
print_help()
sys.exit(0)
console.print()
console.print(f"[red]Unknown command: {args[0]}[/red]")
console.print()
error(f"Unknown command: {args[0]}")
sys.exit(1)
+1 -5
View File
@@ -288,9 +288,5 @@ if __name__ == "__main__":
if handle_command(command, remaining_args):
sys.exit(0)
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]drone @api --help[/dim] for available commands")
console.print()
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
sys.exit(1)
+13 -5
View File
@@ -30,7 +30,7 @@ if sys.platform == "win32":
os.environ.setdefault("AIPASS_BRANCH_NAME", "backup")
from aipass.prax import logger
from aipass.cli.apps.modules import console, header
from aipass.cli.apps.modules import console, error, header
VERSION = "1.0.0"
MODULE_NAME = "backup"
@@ -138,6 +138,14 @@ def main():
return 0
command = args[0]
remaining = args[1:]
if remaining and remaining[0] in ["--help", "-h"]:
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print_help()
return 0
if command == "backup" and len(args) > 1:
from aipass.backup.apps.modules.register import resolve_project
@@ -145,7 +153,7 @@ def main():
target = args[1]
project_root = resolve_project(target)
if project_root is None:
console.print(f"[red]Error:[/red] Cannot resolve project: {target}")
error(f"Cannot resolve project: {target}")
return 1
remaining = [project_root] + args[2:]
mode = "snapshot"
@@ -158,7 +166,7 @@ def main():
if route_command(mode, remaining, modules):
return 0
console.print(f"[red]Error:[/red] Unknown mode: {mode}")
error(f"Unknown mode: {mode}")
return 1
remaining = args[1:] if len(args) > 1 else []
@@ -168,14 +176,14 @@ def main():
resolved = resolve_project(remaining[0])
if resolved is None:
console.print(f"[red]Error:[/red] Cannot resolve project: {remaining[0]}")
error(f"Cannot resolve project: {remaining[0]}")
return 1
remaining = [resolved] + remaining[1:]
if route_command(command, remaining, modules):
return 0
console.print(f"[red]Unknown command:[/red] {command}")
error(f"Unknown command: {command}")
return 1
@@ -14,7 +14,7 @@ import tempfile
from datetime import datetime, timezone
from pathlib import Path
from aipass.prax import logger
from aipass.prax import append_jsonl, logger
def log_operation(operation: str, data: dict) -> None:
@@ -24,12 +24,9 @@ def log_operation(operation: str, data: dict) -> None:
"operation": operation,
**data,
}
log_dir = Path(__file__).resolve().parents[3] / "logs"
log_dir.mkdir(exist_ok=True)
log_file = log_dir / "operations.jsonl"
log_file = Path(__file__).resolve().parents[3] / "logs" / "operations.jsonl"
try:
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
append_jsonl(log_file, entry)
except OSError as e:
logger.warning(f"Failed to write operation log: {e}")
@@ -19,7 +19,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error as cli_error
from aipass.backup.apps.handlers.json import json_handler
@@ -61,7 +61,7 @@ def run_drive_check() -> bool:
console.print(f" Backup folder ID: {result['folder_id']}")
logger.info("[backup] Drive test passed")
else:
console.print(f"[red]Drive connectivity test FAILED: {result['error']}[/red]")
cli_error(f"Drive connectivity test FAILED: {result['error']}")
logger.warning(f"[backup] Drive test failed: {result['error']}")
json_handler.log_operation(
@@ -19,7 +19,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error as cli_error
from aipass.backup.apps.handlers.json import json_handler
@@ -65,7 +65,7 @@ def run_drive_clear(project_root: str, force: bool = False) -> bool:
console.print("[green]Drive tracker cleared.[/green]")
logger.info(f"[backup] Drive tracker cleared for {project_root}")
else:
console.print("[red]Failed to clear Drive tracker.[/red]")
cli_error("Failed to clear Drive tracker.")
json_handler.log_operation(
"drive_clear_complete",
@@ -19,7 +19,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error as cli_error
from aipass.backup.apps.handlers.json import json_handler
@@ -78,7 +78,7 @@ def run_drive_stats(project_root: str) -> bool:
return True
except Exception as exc:
logger.warning(f"Failed to load tracker for {project_root}: {exc}")
console.print(f"[red]Error loading tracker: {exc}[/red]")
cli_error(f"Error loading tracker: {exc}")
return False
+9 -2
View File
@@ -30,6 +30,7 @@ if sys.platform == "win32":
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.backup.apps.handlers.ignore.patterns import is_ignored, load_spec
from aipass.backup.apps.handlers.json import json_handler
from aipass.backup.apps.handlers.path.builder import build_versioned_store
from aipass.backup.apps.modules.display import show_drive_result
@@ -116,8 +117,14 @@ def run_drive_sync(
logger.warning(f"[backup] {result['error']}")
return result
# 3. Scan for ALL files (no dotfile filter — the store is already filtered by .backupignore)
all_files = [f for f in store_path.rglob("*") if f.is_file()]
# 3. Scan store and re-filter through .backupignore (legacy stores may
# contain files swept in before an ignore rule was added).
spec = load_spec(str(project_root))
raw_files = [f for f in store_path.rglob("*") if f.is_file()]
all_files = [f for f in raw_files if not is_ignored(str(f.relative_to(store_path)), spec)]
ignored_count = len(raw_files) - len(all_files)
if ignored_count:
logger.info(f"[backup] Drive sync: filtered {ignored_count} ignored files from store")
result["total"] = len(all_files)
+3 -3
View File
@@ -20,7 +20,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
from aipass.backup.apps.handlers.json import json_handler
from aipass.backup.apps.handlers.project.registry import lookup_project as _lookup_project
@@ -91,12 +91,12 @@ def handle_command(command: str, args: list) -> bool:
name = args[idx + 1]
if not Path(project_path).is_dir():
console.print(f"[red]Error:[/red] {project_path} is not a directory")
error(f"{project_path} is not a directory")
return True
backup_dir = create_backup_dir(project_path)
if backup_dir is None:
console.print(f"[red]Error:[/red] Failed to create .backup/ in {project_path}")
error(f"Failed to create .backup/ in {project_path}")
return True
register_project(name, project_path)
+6 -6
View File
@@ -19,7 +19,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error as cli_error
from aipass.backup.apps.handlers.json import json_handler
@@ -71,10 +71,10 @@ def run_share(file_path: str, *, public: bool = False) -> dict:
client = DriveClient()
if not client.authenticate():
error = f"Drive authentication failed: {client.last_error}"
console.print(f"[red]{error}[/red]")
logger.warning(f"[backup] {error}")
return {"success": False, "link": None, "file_id": None, "error": error}
err_msg = f"Drive authentication failed: {client.last_error}"
cli_error(err_msg)
logger.warning(f"[backup] {err_msg}")
return {"success": False, "link": None, "file_id": None, "error": err_msg}
console.print(f"[dim]Uploading {file_path}...[/dim]")
result = share_file(client, file_path, public=public)
@@ -84,7 +84,7 @@ def run_share(file_path: str, *, public: bool = False) -> dict:
console.print(f"[green]Shared ({mode}):[/green] {result['link']}")
logger.info(f"[backup] Shared {file_path} ({mode})")
else:
console.print(f"[red]Share failed:[/red] {result['error']}")
cli_error(f"Share failed: {result['error']}")
logger.warning(f"[backup] Share failed: {result['error']}")
if result.get("link"):
+71 -5
View File
@@ -549,13 +549,13 @@ class TestDriveUpload:
result = mod.upload_single_file(client, missing, "testproj", tmp_path)
assert result is False
def test_upload_batch_empty(self) -> None:
def test_upload_batch_empty(self, tmp_path: Path) -> None:
"""Empty file list returns success immediately."""
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
client = client_mod.DriveClient()
result = mod.upload_batch(client, [], "proj", Path("/tmp"), {})
result = mod.upload_batch(client, [], "proj", tmp_path, {})
assert result["success"] is True
assert result["uploaded"] == 0
assert result["failed"] == 0
@@ -603,7 +603,7 @@ class TestDriveUpload:
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
mod.MEDIA_UPLOAD_AVAILABLE = False
mod.MEDIA_UPLOAD_AVAILABLE = False # type: ignore[attr-defined]
client = client_mod.DriveClient()
client._drive_service = MagicMock()
@@ -813,6 +813,59 @@ class TestDriveSync:
assert result["uploaded"] == 3
mock_upload_mod.upload_batch.assert_called_once()
def test_run_drive_sync_filters_ignored_files(self, tmp_path: Path) -> None:
"""Files matching .backupignore are excluded from upload list."""
project = tmp_path / "project"
project.mkdir()
bs = project / ".backup" / "versioned"
(bs / "src" / "app.py" / "app.py").parent.mkdir(parents=True)
(bs / "src" / "app.py" / "app.py").write_text("code", encoding="utf-8")
(bs / "node_modules" / "pkg" / "index.js" / "index.js").parent.mkdir(parents=True)
(bs / "node_modules" / "pkg" / "index.js" / "index.js").write_text("junk", encoding="utf-8")
(bs / "node_modules" / "other" / "lib.js" / "lib.js").parent.mkdir(parents=True)
(bs / "node_modules" / "other" / "lib.js" / "lib.js").write_text("junk2", encoding="utf-8")
ignore_file = project / ".backupignore"
ignore_file.write_text("node_modules/\n", encoding="utf-8")
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
mock_class, mock_inst = self._make_mock_client_class(authenticate_rv=True)
mock_client_module = MagicMock()
mock_client_module.DriveClient = mock_class
mock_tracker_mod = MagicMock()
mock_tracker_mod.load_tracker.return_value = {}
mock_tracker_mod.check_needs_upload.return_value = True
mock_tracker_mod.save_tracker = MagicMock()
mock_upload_mod = MagicMock()
mock_upload_mod.upload_batch.return_value = {
"success": True,
"uploaded": 1,
"failed": 0,
}
with (
patch.dict(
sys.modules,
{
"aipass.backup.apps.handlers.drive.client": mock_client_module,
"aipass.backup.apps.handlers.drive.tracker": mock_tracker_mod,
"aipass.backup.apps.handlers.drive.upload": mock_upload_mod,
},
),
patch.object(mod, "build_versioned_store", return_value=bs),
):
result = mod.run_drive_sync(str(project), show_panels=False)
assert result["total"] == 1
uploaded_files = mock_upload_mod.upload_batch.call_args[0][1]
names = [f.name for f in uploaded_files]
assert "app.py" in names
assert "index.js" not in names
assert "lib.js" not in names
def test_handle_command_help(self) -> None:
"""--help returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
@@ -838,14 +891,17 @@ class TestDriveCheckModule:
"""Tests for drive_check module."""
def test_handle_command_primary(self) -> None:
"""Primary command returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
assert mod.handle_command("drive_check", []) is True
def test_handle_command_help(self) -> None:
"""--help returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
assert mod.handle_command("drive_check", ["--help"]) is True
def test_handle_command_wrong(self) -> None:
"""Wrong command returns False."""
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
assert mod.handle_command("wrong", []) is False
@@ -879,14 +935,17 @@ class TestDriveStatsModule:
"""Tests for drive_stats module."""
def test_handle_command_primary(self) -> None:
"""Primary command returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
assert mod.handle_command("drive_stats", []) is True
def test_handle_command_help(self) -> None:
"""--help returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
assert mod.handle_command("drive_stats", ["--help"]) is True
def test_handle_command_wrong(self) -> None:
"""Wrong command returns False."""
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
assert mod.handle_command("wrong", []) is False
@@ -913,21 +972,24 @@ class TestDriveClearModule:
"""Tests for drive_clear module."""
def test_handle_command_primary(self) -> None:
"""Primary command returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
assert mod.handle_command("drive_clear", []) is True
def test_handle_command_help(self) -> None:
"""--help returns True."""
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
assert mod.handle_command("drive_clear", ["--help"]) is True
def test_handle_command_wrong(self) -> None:
"""Wrong command returns False."""
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
assert mod.handle_command("wrong", []) is False
def test_run_drive_clear_no_force(self) -> None:
def test_run_drive_clear_no_force(self, tmp_path: Path) -> None:
"""Without --force, returns False."""
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
result = mod.run_drive_clear("/tmp/project", force=False)
result = mod.run_drive_clear(str(tmp_path / "project"), force=False)
assert result is False
def test_run_drive_clear_with_force(self, tmp_path: Path) -> None:
@@ -1101,24 +1163,28 @@ class TestCommandRouting:
"""Verify drive commands route by underscore names."""
def test_drive_sync_routes_underscore(self) -> None:
"""drive_sync accepts underscore, rejects hyphen."""
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
assert mod.PRIMARY_COMMAND == "drive_sync"
assert mod.handle_command("drive_sync", []) is True
assert mod.handle_command("drive-sync", []) is False
def test_drive_check_routes_underscore(self) -> None:
"""drive_check accepts underscore, rejects hyphen."""
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
assert mod.PRIMARY_COMMAND == "drive_check"
assert mod.handle_command("drive_check", []) is True
assert mod.handle_command("drive-check", []) is False
def test_drive_stats_routes_underscore(self) -> None:
"""drive_stats accepts underscore, rejects hyphen."""
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
assert mod.PRIMARY_COMMAND == "drive_stats"
assert mod.handle_command("drive_stats", []) is True
assert mod.handle_command("drive-stats", []) is False
def test_drive_clear_routes_underscore(self) -> None:
"""drive_clear accepts underscore, rejects hyphen."""
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
assert mod.PRIMARY_COMMAND == "drive_clear"
assert mod.handle_command("drive_clear", []) is True
@@ -127,6 +127,27 @@ class TestLogOperation:
"""
assert callable(json_handler.log_operation)
def test_log_operation_handles_path_objects(self, tmp_path: Path) -> None:
"""log_operation serializes pathlib.Path values via default=str."""
log_dir = tmp_path / "logs"
log_dir.mkdir()
with patch(
"aipass.backup.apps.handlers.json.json_handler.Path",
) as mock_path:
mock_resolve = mock_path.return_value.resolve.return_value
mock_resolve.parents.__getitem__ = lambda self, i: tmp_path
mock_path.return_value.__truediv__ = Path.__truediv__
json_handler.log_operation(
"test_op",
{"project_root": Path("/some/project")},
)
log_file = log_dir / "operations.jsonl"
if log_file.exists():
entry = json.loads(log_file.read_text(encoding="utf-8").strip())
# default=str serializes via str(Path(...)) — platform-native separators,
# so compare against the same (POSIX "/some/project", Windows "\some\project").
assert entry["project_root"] == str(Path("/some/project"))
class TestEnsureAndGetPath:
"""Token coverage for standard json_handler API that backup doesn't implement.
+7
View File
@@ -282,6 +282,13 @@ def main() -> int:
command = args[0]
remaining = args[1:] if len(args) > 1 else []
if remaining and remaining[0] in ["--help", "-h"]:
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print_help()
return 0
# Route to modules
if route_command(command, remaining, modules):
return 0
+8
View File
@@ -26,6 +26,9 @@ from aipass.cli.apps.modules.display import console, err_console
# Display functions
from aipass.cli.apps.modules.display import header, success, error, warning, fatal, section
# Exit-code failure-flag API
from aipass.cli.apps.modules.display import mark_command_failed, command_failed, reset_command_state, resolve_exit
# Operation templates
from aipass.cli.apps.modules.templates import operation_start, operation_complete
@@ -40,6 +43,11 @@ __all__ = [
"warning",
"fatal",
"section",
# Exit-code failure-flag API
"mark_command_failed",
"command_failed",
"reset_command_state",
"resolve_exit",
# Templates
"operation_start",
"operation_complete",
+54 -6
View File
@@ -28,6 +28,7 @@ from typing import Dict, Any, Optional, List
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.columns import Columns
from aipass.cli.apps.handlers.json import json_handler
@@ -48,6 +49,36 @@ err_console = Console(stderr=True, force_terminal=sys.stderr.isatty()) # Stderr
_TRIGGER = None
_TRIGGER_LOADED = False
# Process-level command failure flag — mutable container avoids global statement
_CMD_STATE = {"failed": False}
def mark_command_failed() -> None:
"""Set the process-level failure flag (called automatically by error())."""
_CMD_STATE["failed"] = True
def command_failed() -> bool:
"""Return whether mark_command_failed() has been called since last reset."""
return _CMD_STATE["failed"]
def reset_command_state() -> None:
"""Reset the failure flag to False (for tests and main() entry)."""
_CMD_STATE["failed"] = False
def resolve_exit(handled: bool) -> int:
"""Map handled/failed state to an exit code.
Returns 1 if not handled, 2 if handled but failed, 0 otherwise.
"""
if not handled:
return 1
if _CMD_STATE["failed"]:
return 2
return 0
# ============================================================================
# MODULE PATTERN FUNCTIONS (SEEDGO compliant)
@@ -341,9 +372,14 @@ def error(message: str, suggestion: str | None = None) -> None:
Example:
error('Branch not found', suggestion='Check branch name spelling')
"""
err_console.print(f"❌ [red bold]{message}[/red bold]")
mark_command_failed()
msg = Text("❌ ")
msg.append(message, style="red bold")
err_console.print(msg)
if suggestion:
err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]")
hint = Text(" → Try: ")
hint.append(suggestion, style="yellow")
err_console.print(hint)
def warning(message: str, details: str | None = None) -> None:
@@ -357,9 +393,13 @@ def warning(message: str, details: str | None = None) -> None:
Example:
warning('Branch already exists, skipping')
"""
err_console.print(f"⚠️ [yellow]{message}[/yellow]")
msg = Text("⚠️ ")
msg.append(message, style="yellow")
err_console.print(msg)
if details:
err_console.print(f" [dim]{details}[/dim]")
detail_text = Text(" ")
detail_text.append(details, style="dim")
err_console.print(detail_text)
def fatal(message: str, suggestion: str | None = None) -> None:
@@ -375,9 +415,13 @@ def fatal(message: str, suggestion: str | None = None) -> None:
Example:
fatal('Config file missing', suggestion='Run aipass init first')
"""
err_console.print(f"❌ [red bold]{message}[/red bold]")
msg = Text("❌ ")
msg.append(message, style="red bold")
err_console.print(msg)
if suggestion:
err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]")
hint = Text(" → Try: ")
hint.append(suggestion, style="yellow")
err_console.print(hint)
sys.exit(1)
@@ -411,6 +455,10 @@ __all__ = [
"warning",
"fatal",
"section",
"mark_command_failed",
"command_failed",
"reset_command_state",
"resolve_exit",
]
# ============================================================================
+59
View File
@@ -522,3 +522,62 @@ class TestInfrastructureMocking:
module_key = "aipass.cli.apps.modules.display"
assert module_key in sys.modules
assert sys.modules[module_key] is display
# =============================================================================
# Exit-code failure-flag tests
# =============================================================================
class TestCommandState:
"""Verify the process-level failure flag and resolve_exit truth table."""
def setup_method(self):
display.reset_command_state()
def teardown_method(self):
display.reset_command_state()
def test_initial_state_is_not_failed(self):
assert display.command_failed() is False
def test_mark_command_failed_sets_flag(self):
display.mark_command_failed()
assert display.command_failed() is True
def test_reset_command_state_clears_flag(self):
display.mark_command_failed()
display.reset_command_state()
assert display.command_failed() is False
def test_resolve_exit_not_handled(self):
assert display.resolve_exit(handled=False) == 1
def test_resolve_exit_handled_ok(self):
assert display.resolve_exit(handled=True) == 0
def test_resolve_exit_handled_failed(self):
display.mark_command_failed()
assert display.resolve_exit(handled=True) == 2
def test_resolve_exit_not_handled_ignores_flag(self):
display.mark_command_failed()
assert display.resolve_exit(handled=False) == 1
def test_error_trips_failure_flag(self):
cons, _ = _make_capture_console()
with patch.object(display, "err_console", cons):
display.error("something broke")
assert display.command_failed() is True
def test_warning_does_not_trip_flag(self):
cons, _ = _make_capture_console()
with patch.object(display, "err_console", cons):
display.warning("just a warning")
assert display.command_failed() is False
def test_success_does_not_trip_flag(self):
cons, _ = _make_capture_console()
with patch.object(display, "CONSOLE", cons):
display.success("all good")
assert display.command_failed() is False
+1 -1
View File
@@ -292,7 +292,7 @@ def print_introspection(modules: List[Any]) -> None:
console.print("[dim]A gathering place where branches post, comment, vote, and discuss.[/dim]")
console.print()
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
warning(f"Discovered Modules: {len(modules)}")
console.print()
if modules:
+3 -2
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[activity] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from rich.table import Table
@@ -81,7 +82,7 @@ def _handle_activity(args: List[str]) -> bool:
if not result["success"]:
if result.get("error"):
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result.get("help"):
+15 -12
View File
@@ -20,12 +20,15 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success, warning
except ImportError:
logger.warning("[artifact] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
warning = console.print # type: ignore[assignment]
from rich.panel import Panel
from rich.table import Table
@@ -96,12 +99,12 @@ def handle_command(command: str, args: List[str]) -> bool:
def _handle_craft(args: List[str]) -> bool:
result = craft_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
console.print()
console.print("[green]Artifact crafted![/green]")
success("Artifact crafted!")
console.print(f" [dim]ID:[/dim] {result['artifact_id']}")
console.print(f" [dim]Name:[/dim] {result['name']}")
console.print(f" [dim]Type:[/dim] {result['type']}")
@@ -115,7 +118,7 @@ def _handle_craft(args: List[str]) -> bool:
def _handle_list(args: List[str]) -> bool:
result = list_artifacts(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
artifacts = result["artifacts"]
@@ -155,7 +158,7 @@ def _handle_list(args: List[str]) -> bool:
def _handle_inspect(args: List[str]) -> bool:
result = inspect_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
artifact = result["artifact"]
@@ -212,7 +215,7 @@ def _handle_inspect(args: List[str]) -> bool:
elif action == "found":
console.print(f" [yellow]*[/yellow] {timestamp[:19]} | Found by {to_agent}")
elif action == "expired":
console.print(f" [red]x[/red] {timestamp[:19]} | Expired: {entry.get('details', '')}")
console.print(f" [dim]x[/dim] {timestamp[:19]} | Expired: {entry.get('details', '')}")
else:
console.print(f" [dim]-[/dim] {timestamp[:19]} | {action.title()}: {entry.get('details', '')}")
@@ -228,15 +231,15 @@ def _handle_inspect(args: List[str]) -> bool:
def _handle_collab(args: List[str]) -> bool:
result = collab_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
for warning in result.get("warnings", []):
console.print(f"[yellow]Warning: {warning}[/yellow]")
for warn_msg in result.get("warnings", []):
warning(f"Warning: {warn_msg}")
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
console.print()
console.print("[green]Joint artifact initiated![/green]")
success("Joint artifact initiated!")
console.print(f" [dim]Pending ID:[/dim] {result['pending_id']}")
console.print(f" [dim]Name:[/dim] {result['name']}")
console.print(f" [dim]Rarity:[/dim] [{rarity_color}]{result['rarity']}[/{rarity_color}]")
@@ -252,13 +255,13 @@ def _handle_collab(args: List[str]) -> bool:
def _handle_sign(args: List[str]) -> bool:
result = sign_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result["completed"]:
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
console.print()
console.print("[bold green]Joint artifact completed![/bold green]")
success("Joint artifact completed!")
console.print(f" [dim]Artifact ID:[/dim] {result['artifact_id']}")
console.print(f" [dim]Name:[/dim] [{rarity_color}]{result['name']}[/{rarity_color}]")
console.print(f" [dim]Rarity:[/dim] [{rarity_color}]{result['rarity']}[/{rarity_color}]")
+5 -4
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[capsule] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from rich.panel import Panel
from rich.table import Table
@@ -84,7 +85,7 @@ def handle_command(command: str, args: List[str]) -> bool:
def _handle_seal(args: List[str]) -> bool:
result = seal_capsule(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
@@ -109,7 +110,7 @@ def _handle_seal(args: List[str]) -> bool:
def _handle_list(args: List[str]) -> bool:
result = list_capsules(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
capsules = result["capsules"]
@@ -152,7 +153,7 @@ def _handle_list(args: List[str]) -> bool:
def _handle_open(args: List[str]) -> bool:
result = open_capsule(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
capsule = result["capsule"]
+4 -3
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[catchup] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.catchup.catchup_ops import run_catchup
from aipass.commons.apps.handlers.json import json_handler
@@ -75,7 +76,7 @@ def _handle_catchup(args: List[str]) -> bool:
result = run_catchup(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
is_first_visit = result["is_first_visit"]
@@ -139,7 +140,7 @@ def _handle_catchup(args: List[str]) -> bool:
if karma_change > 0:
console.print(f" [green]KARMA:[/green] +{karma_change} since last session")
elif karma_change < 0:
console.print(f" [red]KARMA:[/red] {karma_change} since last session")
error(f"KARMA: {karma_change} since last session")
else:
console.print(" [dim]KARMA:[/dim] [dim]No change[/dim]")
+5 -3
View File
@@ -21,12 +21,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[central] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.central.central_writer import update_central
from aipass.commons.apps.handlers.json import json_handler
@@ -74,10 +76,10 @@ def handle_command(command: str, args: List[str]) -> bool:
try:
stats = update_central()
branch_count = len(stats.get("branch_stats", {}))
console.print(f"[green]Central file updated:[/green] {branch_count} branches")
success(f"Central file updated: {branch_count} branches")
json_handler.log_operation("push-central_executed", {"command": "push-central", "success": True})
return True
except Exception as e:
logger.error(f"[commons] push-central failed: {e}")
console.print(f"[red]Error:[/red] {e}")
error(f"Error: {e}")
return True
+8 -8
View File
@@ -20,12 +20,15 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[comment] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.comments.comment_ops import add_comment, vote_on_content
from aipass.commons.apps.handlers.identity.identity_ops import resolve_display_name
@@ -85,12 +88,12 @@ def _handle_comment(args: List[str]) -> bool:
result = add_comment(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
parent_note = f" (reply to comment {result['parent_id']})" if result.get("parent_id") else ""
console.print()
console.print(f"[green]Comment added to post {result['post_id']}{parent_note}[/green]")
success(f"Comment added to post {result['post_id']}{parent_note}")
console.print(f" [dim]Comment ID:[/dim] {result['comment_id']}")
console.print(f" [dim]Author:[/dim] {resolve_display_name(result['author'])}")
if result.get("mentions"):
@@ -105,7 +108,7 @@ def _handle_vote(args: List[str]) -> bool:
result = vote_on_content(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
action_msg = {
@@ -117,10 +120,7 @@ def _handle_vote(args: List[str]) -> bool:
arrow = "^" if result["direction"] == "up" else "v"
console.print()
console.print(
f"[green]{arrow} {action_msg} on {result['target_type']} "
f"{result['target_id']}[/green] [dim](score: {result['new_score']})[/dim]"
)
success(f"{arrow} {action_msg} on {result['target_type']} {result['target_id']} (score: {result['new_score']})")
console.print()
return True
@@ -23,22 +23,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules.display import error, warning
from aipass.cli.apps.modules import console, error, warning
except ImportError:
logger.warning("[commons_identity] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
def error(message: str, suggestion: str | None = None) -> None:
"""Display error message in red."""
console.print(f"[red]{message}[/red]")
def warning(message: str, details: str | None = None) -> None:
"""Display warning message in yellow."""
console.print(f"[yellow]{message}[/yellow]")
error = console.print # type: ignore[assignment]
warning = console.print # type: ignore[assignment]
# Re-export all public functions for backward compatibility
from aipass.commons.apps.handlers.identity.identity_ops import (
@@ -133,5 +125,5 @@ def _handle_whoami(args: List[str]) -> bool:
except Exception as e:
logger.error(f"[commons.identity] whoami failed: {e}")
console.print(f"[red]Error detecting identity:[/red] {e}")
error(f"Error detecting identity: {e}")
return True
+3 -2
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[digest] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from rich.panel import Panel
@@ -79,7 +80,7 @@ def _handle_digest(args: List[str]) -> bool:
result = show_digest(args)
if not result["success"]:
console.print(f"[red]Failed to generate digest: {result['error']}[/red]")
error(f"Failed to generate digest: {result['error']}")
return True
top_posts = result["top_posts"]
@@ -21,12 +21,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[engagement] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.engagement.engagement_ops import generate_prompt, create_event
from aipass.commons.apps.handlers.json import json_handler
@@ -88,7 +90,7 @@ def _handle_prompt(args: List[str]) -> bool:
result = generate_prompt(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result.get("dry_run"):
@@ -100,7 +102,7 @@ def _handle_prompt(args: List[str]) -> bool:
return True
console.print()
console.print("[green]Daily prompt posted![/green]")
success("Daily prompt posted!")
console.print(f" [dim]ID:[/dim] {result['post_id']}")
console.print(f" [dim]Room:[/dim] r/{result['room']}")
console.print(f" [dim]Theme:[/dim] {result['theme']}")
@@ -115,7 +117,7 @@ def _handle_event(args: List[str]) -> bool:
result = create_event(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result.get("dry_run"):
@@ -127,7 +129,7 @@ def _handle_event(args: List[str]) -> bool:
return True
console.print()
console.print("[green]Event created![/green]")
success("Event created!")
console.print(f" [dim]ID:[/dim] {result['post_id']}")
console.print(f" [dim]Room:[/dim] r/{result['room']}")
console.print(f" [dim]Title:[/dim] {result['title']}")
+7 -5
View File
@@ -20,12 +20,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[explore] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from rich.panel import Panel
from rich.table import Table
@@ -77,7 +79,7 @@ def handle_command(command: str, args: List[str]) -> bool:
def _handle_explore(args: List[str]) -> bool:
result = explore_rooms(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
hidden_rooms = result["hidden_rooms"]
@@ -108,8 +110,8 @@ def _handle_explore(args: List[str]) -> bool:
revealed = result.get("revealed")
if revealed:
console.print(f"[green]Your exploration has paid off! You've visited {rooms_visited} rooms.[/green]")
console.print(f"[green]A secret room reveals itself:[/green] [bold magenta]r/{revealed['name']}[/bold magenta]")
success(f"Your exploration has paid off! You've visited {rooms_visited} rooms.")
success(f"A secret room reveals itself: r/{revealed['name']}")
console.print(f" [dim]{revealed['description']}[/dim]")
console.print()
console.print(f"[dim]Try: commons enter {revealed['name']}[/dim]")
@@ -126,7 +128,7 @@ def _handle_explore(args: List[str]) -> bool:
def _handle_secrets(args: List[str]) -> bool:
result = list_secrets(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
discovered = result["discovered"]
+3 -2
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[feed] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from rich.table import Table
@@ -81,7 +82,7 @@ def _handle_feed(args: List[str]) -> bool:
result = display_feed(args)
if not result["success"]:
console.print(f"[red]Feed error: {result['error']}[/red]")
error(f"Feed error: {result['error']}")
return True
posts = result["posts"]
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[leaderboard] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from rich.table import Table
@@ -94,7 +95,7 @@ def _handle_leaderboard(args: List[str]) -> bool:
result = show_leaderboard(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
boards = result["boards"]
@@ -21,12 +21,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[notification] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.notifications.notification_ops import (
set_watch,
@@ -110,7 +111,7 @@ LEVEL_LABELS = {
def _handle_level(result: dict, level: str) -> bool:
"""Display the result of setting a notification level."""
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
label, color, description = LEVEL_LABELS[level]
@@ -127,7 +128,7 @@ def _handle_preferences(args: List[str]) -> bool:
result = show_preferences(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
prefs = result["preferences"]
+8 -6
View File
@@ -20,12 +20,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[post] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from rich.panel import Panel
from rich.text import Text
@@ -91,11 +93,11 @@ def _handle_create_post(args: List[str]) -> bool:
result = create_post(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f"[green]Post created in r/{result['room']}[/green]")
success(f"Post created in r/{result['room']}")
console.print(f" [dim]ID:[/dim] {result['post_id']}")
console.print(f" [dim]Title:[/dim] {result['title']}")
console.print(f" [dim]Type:[/dim] {result['post_type']}")
@@ -112,7 +114,7 @@ def _handle_view_thread(args: List[str]) -> bool:
result = view_thread(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
post = result["post"]
@@ -187,8 +189,8 @@ def _handle_delete_post(args: List[str]) -> bool:
result = delete_post(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print(f"[green]Post {result['post_id']} deleted.[/green]")
success(f"Post {result['post_id']} deleted.")
return True
+6 -4
View File
@@ -21,12 +21,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[profile] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from rich.panel import Panel
@@ -85,11 +87,11 @@ def _handle_profile(args: List[str]) -> bool:
result = show_profile(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result["action"] == "set":
console.print(f"[green]Updated {result['field']} for {result['branch']}[/green]")
success(f"Updated {result['field']} for {result['branch']}")
return True
# View profile
@@ -128,7 +130,7 @@ def _handle_who(args: List[str]) -> bool:
result = list_members(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
agents = result["agents"]
+12 -10
View File
@@ -21,12 +21,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[reaction] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.curation.curation_ops import (
add_react,
@@ -127,7 +129,7 @@ def _handle_react(args: List[str]) -> bool:
result = add_react(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
emoji = result["emoji"]
@@ -151,7 +153,7 @@ def _handle_unreact(args: List[str]) -> bool:
result = remove_react(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
emoji = result["emoji"]
@@ -174,7 +176,7 @@ def _handle_reactions(args: List[str]) -> bool:
result = show_reactions(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
detailed = result["reactions"]
@@ -200,11 +202,11 @@ def _handle_pin(args: List[str]) -> bool:
result = pin_post_cmd(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f'[green]Pinned post #{result["post_id"]} "{result["title"]}"[/green]')
success(f'Pinned post #{result["post_id"]} "{result["title"]}"')
console.print()
return True
@@ -215,11 +217,11 @@ def _handle_unpin(args: List[str]) -> bool:
result = unpin_post_cmd(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f'[green]Unpinned post #{result["post_id"]} "{result["title"]}"[/green]')
success(f'Unpinned post #{result["post_id"]} "{result["title"]}"')
console.print()
return True
@@ -230,7 +232,7 @@ def _handle_pinned(args: List[str]) -> bool:
result = show_pinned(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
posts = result["posts"]
@@ -260,7 +262,7 @@ def _handle_trending(args: List[str]) -> bool:
result = show_trending(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
posts = result["posts"]
+11 -9
View File
@@ -20,12 +20,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[room] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from rich.table import Table
@@ -82,7 +84,7 @@ def handle_command(command: str, args: List[str]) -> bool:
elif subcommand == "leave":
result = _handle_leave_room(sub_args)
else:
console.print(f"[red]Unknown room subcommand: {subcommand}[/red]")
error(f"Unknown room subcommand: {subcommand}")
console.print("[dim]Available: create, list, join, leave[/dim]")
return True
@@ -101,11 +103,11 @@ def _handle_create_room(args: List[str]) -> bool:
result = create_room(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f"[green]Room '{result['name']}' created![/green]")
success(f"Room '{result['name']}' created!")
if result.get("description"):
console.print(f" [dim]Description:[/dim] {result['description']}")
console.print(f" [dim]Created by:[/dim] {result['created_by']}")
@@ -119,7 +121,7 @@ def _handle_list_rooms(args: List[str]) -> bool:
result = list_rooms(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
rooms = result["rooms"]
@@ -158,11 +160,11 @@ def _handle_join_room(args: List[str]) -> bool:
result = join_room(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f"[green]{result['agent']} joined room '{result['room']}'![/green]")
success(f"{result['agent']} joined room '{result['room']}'!")
console.print()
return True
@@ -173,11 +175,11 @@ def _handle_leave_room(args: List[str]) -> bool:
result = leave_room(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
console.print(f"[green]{result['agent']} left room '{result['room']}'.[/green]")
success(f"{result['agent']} left room '{result['room']}'.")
console.print()
return True
+4 -3
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[search] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.search.search_ops import run_search, run_log_export
from aipass.commons.apps.handlers.json import json_handler
@@ -82,7 +83,7 @@ def _handle_search(args: List[str]) -> bool:
result = run_search(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
posts = result["posts"]
@@ -137,7 +138,7 @@ def _handle_log(args: List[str]) -> bool:
result = run_log_export(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
+13 -21
View File
@@ -20,22 +20,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, success
except ImportError:
logger.warning("[space] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
try:
from aipass.cli.apps.modules.display import error
except ImportError:
logger.warning("[space] CLI error function unavailable, using fallback")
def error(message, suggestion=None):
"""Display error message in red."""
console.print(f"[red]{message}[/red]") # type: ignore[assignment]
error = console.print # type: ignore[assignment]
success = console.print # type: ignore[assignment]
from rich.panel import Panel
@@ -146,18 +138,18 @@ def handle_command(command: str, args: List[str]) -> bool:
def _cmd_enter(args: List[str]) -> bool:
"""Enter a room -- render entrance panel with mood, flavor, decorations."""
if not args:
console.print("[red]Usage: commons enter <room>[/red]")
error("Usage: commons enter <room>")
return True
room_name = args[0].lower()
data = get_room_enter_data(room_name)
if data.get("error"):
console.print(f"[red]{data['error']}[/red]")
error(data["error"])
return True
if not data["found"]:
console.print(f"[red]Room '{room_name}' not found[/red]")
error(f"Room '{room_name}' not found")
return True
room = data["room"]
@@ -220,11 +212,11 @@ def _cmd_look(args: List[str]) -> bool:
data = get_room_look_data(room_name)
if data.get("error"):
console.print(f"[red]{data['error']}[/red]")
error(data["error"])
return True
if not data["found"]:
console.print(f"[red]Room '{room_name}' not found[/red]")
error(f"Room '{room_name}' not found")
return True
room = data["room"]
@@ -294,11 +286,11 @@ def _cmd_decorate(args: List[str]) -> bool:
if result["success"]:
console.print()
console.print(f"[green]Placed '{result['display_name']}' in r/{room_name}[/green]")
success(f"Placed '{result['display_name']}' in r/{room_name}")
console.print(f" [dim]{description}[/dim]")
console.print()
else:
console.print("[red]Failed to place decoration[/red]")
error("Failed to place decoration")
return True
@@ -311,18 +303,18 @@ def _cmd_decorate(args: List[str]) -> bool:
def _cmd_visitors(args: List[str]) -> bool:
"""Show recent visitors in a room (last 48h)."""
if not args:
console.print("[red]Usage: commons visitors <room>[/red]")
error("Usage: commons visitors <room>")
return True
room_name = args[0].lower()
data = get_visitors_data(room_name)
if data.get("error"):
console.print(f"[red]{data['error']}[/red]")
error(data["error"])
return True
if not data["found"]:
console.print(f"[red]Room '{room_name}' not found[/red]")
error(f"Room '{room_name}' not found")
return True
visitors = data["visitors"]
+10 -8
View File
@@ -20,12 +20,14 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error, warning
except ImportError:
logger.warning("[trade] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
warning = console.print # type: ignore[assignment]
from rich.panel import Panel
@@ -97,7 +99,7 @@ def handle_command(command: str, args: List[str]) -> bool:
def _handle_gift(args: List[str]) -> bool:
result = gift_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
@@ -119,7 +121,7 @@ def _handle_gift(args: List[str]) -> bool:
def _handle_trade(args: List[str]) -> bool:
result = trade_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
yours = result["your_artifact"]
@@ -147,7 +149,7 @@ def _handle_trade(args: List[str]) -> bool:
def _handle_drop(args: List[str]) -> bool:
result = drop_item(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
console.print()
@@ -170,7 +172,7 @@ def _handle_drop(args: List[str]) -> bool:
def _handle_find(args: List[str]) -> bool:
result = find_item(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
@@ -194,11 +196,11 @@ def _handle_find(args: List[str]) -> bool:
def _handle_mint(args: List[str]) -> bool:
result = mint_event_artifact(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
for warning in result.get("warnings", []):
console.print(f"[yellow]Warning: {warning}[/yellow]")
for warn_msg in result.get("warnings", []):
warning(f"Warning: {warn_msg}")
minted = result["minted"]
lines = [f"[bold]Event:[/bold] {result['event_name']}\n"]
+3 -2
View File
@@ -20,12 +20,13 @@ from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
try:
from aipass.cli.apps.modules import console
from aipass.cli.apps.modules import console, error
except ImportError:
logger.warning("[welcome] CLI console unavailable, using fallback")
from rich.console import Console
console = Console()
error = console.print # type: ignore[assignment]
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
from aipass.commons.apps.handlers.json import json_handler
@@ -75,7 +76,7 @@ def _handle_welcome(args: List[str]) -> bool:
result = run_welcome(args)
if not result["success"]:
console.print(f"[red]{result['error']}[/red]")
error(result["error"])
return True
if result.get("dry_run"):
+12 -4
View File
@@ -110,7 +110,7 @@ def print_introspection(modules: List[Any]):
console.print(" [dim]No modules discovered[/dim]")
console.print()
console.print("[dim]Run 'daemon --help' for usage information[/dim]")
console.print("[dim]Run 'drone @daemon --help' for usage information[/dim]")
console.print()
@@ -132,8 +132,8 @@ def print_help(modules: List[Any]):
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]daemon <command> [args...][/dim]")
console.print(" [dim]daemon --help[/dim]")
console.print(" [dim]drone @daemon <command> [args...][/dim]")
console.print(" [dim]drone @daemon --help[/dim]")
console.print()
console.print("-" * 70)
console.print()
@@ -200,6 +200,14 @@ def main():
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Subcommand --help guard — intercept before dispatch
if remaining_args and remaining_args[0] in ["--help", "-h"]:
for module in modules:
if module.handle_command(command, ["--help"]):
return 0
print_help(modules)
return 0
json_handler.log_operation("daemon_command", {"command": command})
# Route to modules
@@ -208,7 +216,7 @@ def main():
return 0
else:
console.print()
error(f"Unknown command: {command}", suggestion="Run 'daemon --help' for available commands")
error(f"Unknown command: {command}", suggestion="Run 'drone @daemon --help' for available commands")
console.print()
return 1
@@ -68,7 +68,7 @@ def _run_systemctl(*args: str) -> bool:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
logger.warning("[timer_install] systemctl --user %s failed: %s", " ".join(args), result.stderr.strip())
console.print(f" [red]FAIL:[/red] systemctl --user {' '.join(args)}")
error(f"FAIL: systemctl --user {' '.join(args)}")
if result.stderr.strip():
console.print(f" [dim]{result.stderr.strip()}[/dim]")
return False
@@ -79,7 +79,7 @@ def _run_systemctl(*args: str) -> bool:
return False
except subprocess.TimeoutExpired:
logger.error("[timer_install] systemctl --user %s timed out", " ".join(args))
console.print(" [red]systemctl timed out[/red]")
error("systemctl timed out")
return False
+2 -2
View File
@@ -27,7 +27,7 @@ if sys.platform == "win32":
from aipass.prax import logger
from aipass.cli.apps.modules import console, error
from aipass.cli.apps.modules import console, error, warning
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.update.data_loader import (
load_inbox,
@@ -117,7 +117,7 @@ def _print_digest(inbox_data: Dict[str, Any], local_data: Dict[str, Any]) -> Non
console.print(" Recently completed: [dim]None[/dim]")
console.print()
console.print("[bold red]ESCALATIONS NEEDED[/bold red]")
warning("ESCALATIONS NEEDED")
escalations = get_escalations(messages)
if escalations:
for msg in escalations:
@@ -79,9 +79,14 @@ drone @flow create . "Subject" aplan # APLAN (FPLAN/DPLAN
drone @flow list open # active plans
```
# Dispatch — in-flight comms
- **Steer a working agent with `email` (no wake), NOT `dispatch`.** `dispatch` = send **+ wake** (hand NEW work to a sleeping agent). An agent already running is awake, so `drone @ai_mail email @target "Subject" "Msg"` reaches it mid-task via its hook — no re-wake, no interrupt. Forgot something / need to correct a brief / add context → **email it in-flight**, don't re-dispatch. (`drone @ai_mail --help`)
- **No backticks in dispatch/email body strings** — bash runs `` `word` `` as command-substitution and silently eats it. Use single quotes or plain text (hit this live: a backtick'd word vanished from a brief).
# Watchdog
Devpulse module. After dispatch, arm as a background task — it polls the dispatch lock and exits when the agent finishes. Resolves @target → branch path → `.ai_mail.local/.dispatch.lock`. Default timeout 1800s; `drone @devpulse watchdog --help` for the full reference.
Devpulse module. After dispatch, arm as a background task — it polls the dispatch lock and exits when the agent finishes. Resolves @target → branch path → `.ai_mail.local/.dispatch.lock`. Default timeout **600s** — pass `--timeout <s>` for longer builds (verified live S300; `drone @devpulse watchdog --help` for the full reference).
```
drone @ai_mail dispatch @target "Subject" "Body"
+25
View File
@@ -5,6 +5,21 @@
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"standard": "json_structure",
"file": "devpulse_json/compass",
"reason": "compass/ is the devpulse-owned Compass decision store (SQLite/FTS5 — db + wal + shm) which needs its own directory. Legitimate data subdir, not operator-config (custom_config/) nor auto-gen root data. Sanctioned exception per #643."
},
{
"standard": "handlers",
"file": "apps/handlers/owner/guard.py",
"reason": "Lazy-imports is_owner/get_owner from spawn.apps.handlers.registry inside _owner_decision() — is_owner is the frozen shared owner-capability contract (#191, TDPLAN-0012); spawn is its sole home, no modules re-export. Same authorized cross-branch primitive pattern as ai_mail dispatch_monitor. Import failure falls back to the legacy heuristic. #681."
},
{
"standard": "encapsulation",
"file": "apps/handlers/owner/guard.py",
"reason": "Lazy cross-branch import of the is_owner/get_owner resolver from spawn.apps.handlers.registry — the frozen owner-capability contract consumed identically by hooks + ai_mail. No spawn modules-level re-export exists; this is the sanctioned entry point. #681."
},
{
"standard": "architecture",
"reason": "No 'manager' citizen_class template in spawn. Devpulse is the only manager branch."
@@ -45,6 +60,16 @@
"file": "tests/test_git_gate.py",
"reason": "Test imports git_gate.py from ~/.claude/hooks/ via importlib — external hook, not a branch module."
},
{
"standard": "encapsulation",
"file": "tests/test_feedback_module.py",
"reason": "Router test imports the feedback storage handler directly to arrange/assert inbox state (save_inbox/load_inbox) — standard test-fixture access, same pattern as test_feedback_storage.py / test_compass_store.py. #681."
},
{
"standard": "encapsulation",
"file": "tests/test_owner_guard.py",
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
},
{
"standard": "encapsulation",
"file": "tools/spot_check.py",
+40 -6
View File
@@ -44,7 +44,7 @@ src/aipass/devpulse/
│ │ └── watchdog/ # Agent, timer, schedule, registry
│ └── plugins/ # Plugin extension point
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
├── tests/ # 282 tests
├── tests/ # 309 tests
├── artifacts/ # Birth certificate, reports
├── dropbox/ # Received files, archived plans, install audit
├── docs/ # Transition notes
@@ -55,11 +55,38 @@ src/aipass/devpulse/
All commands via `drone @devpulse <command>`:
### Watchdog — directed wake system
### Watchdog — directed wake system (owner-only)
**Who may call it:** the project OWNER only — the first agent, seated as `owner: true`
in the project's sealed `*_REGISTRY.json`. Portable: `@devpulse` in AIPass, `@vera` in
Vera Studio, whoever owns elsewhere. A refusal means your project's owner isn't seated —
run `aipass doctor` to see why and `aipass doctor --fix` to repair (DPLAN-0239).
**How the wake works (read this once, save a debugging session):**
1. `drone @ai_mail dispatch @target "Subject" "Body"` — hand off the work.
2. **Immediately arm the watchdog via the harness Monitor TOOL** — never Bash
`run_in_background` (its output goes nowhere and cannot wake you):
`drone @devpulse watchdog agent @target --timeout 600`
3. The status line shows **"1 monitor"** the moment it's armed — that IS the
active-dispatch indicator. When `@target` finishes, the watchdog exits, the
Monitor completes, and **your session is re-invoked with the result — that IS
the wake.**
There is no passive wake: ai_mail's wake-back spawns a new headless process and can
never inject into a live interactive session (`BLOCKED — interactive session` in the
logs is that guard working as designed; it only serves senders whose session closed).
If you dispatched and idle without arming, nothing will ever wake you.
`@target` resolves in the **caller's own project** (then falls back to scanning
`~/Projects` registries) — external-project owners monitor their own agents with it.
Default timeout is **600 s**; pass `--timeout <s>` for longer builds. Mid-watch it
also emits `[watchdog.stall]` / `[watchdog.resumed]` events (no JSONL activity 120 s
with no in-flight tool = probable stuck agent).
| Command | What it does |
|---|---|
| `watchdog agent @target` | Monitor dispatched agent until it finishes |
| `watchdog agent @target [--timeout s]` | Wake when the dispatched agent exits (default 600 s) |
| `watchdog timer <duration>` | Wake after duration (5m, 30s, 2h, 1h30m) |
| `watchdog timer start/stop <name>` | Named duration tracking |
| `watchdog schedule <HH:MM>` | Wait until a specific time |
@@ -67,7 +94,14 @@ All commands via `drone @devpulse <command>`:
| `watchdog cancel <id>` | Cancel a running watchdog |
| `watchdog list` | List all watchdog entries |
### Feedback — personal cross-branch mailbox
### Feedback — the owner-to-owner channel (owner-only)
ai_mail and dispatch stop at the project boundary — **cross-project comms is
impossible by design, except feedback.** Project owners (managers) talk owner-to-owner
through it: an external project's owner runs `drone @devpulse feedback send ...` from
their project and it lands in devpulse's feedback mailbox; devpulse answers with
`feedback reply`. Same owner gate as watchdog — unseated projects are refused until
`aipass doctor --fix` seats them.
| Command | What it does |
|---|---|
@@ -75,7 +109,7 @@ All commands via `drone @devpulse <command>`:
| `feedback inbox` | List all messages |
| `feedback view <id>` | Read a message |
| `feedback reply <id> "msg"` | Reply to sender |
| `feedback send "subject" "body"` | Receive feedback from another agent |
| `feedback send "subject" "body"` | Send feedback to devpulse (any project's owner may call) |
### Compass — rated decision store
@@ -122,7 +156,7 @@ drone @git log # Recent commits
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-branch communication. Git operations (commit, PR, merge) for the entire project.
*Last Updated: 2026-06-23*
*Last Updated: 2026-07-11*
---
+3 -2
View File
@@ -35,7 +35,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
from aipass.cli.apps.modules import err_console
from aipass.cli.apps.modules import err_console, resolve_exit, reset_command_state
console = err_console
@@ -161,13 +161,14 @@ def _handle_command(command: str, args: list) -> bool:
def main():
"""Main entry point - routes commands or shows help."""
reset_command_state()
args = sys.argv[1:]
if len(args) == 0:
print_introspection()
return 0
return 0 if _handle_command(args[0], args[1:]) else 1
return resolve_exit(_handle_command(args[0], args[1:]))
if __name__ == "__main__":
@@ -25,7 +25,7 @@ from aipass.devpulse.apps.handlers.feedback.storage import (
generate_id,
)
from aipass.cli.apps.modules import err_console
from aipass.cli.apps.modules import err_console, error
from aipass.devpulse.apps.handlers.json import json_handler
console = err_console
@@ -133,7 +133,7 @@ def reply_to(msg_id: str, body: str) -> bool:
break
if msg is None:
console.print(f"[red]Message {msg_id} not found.[/red]")
error(f"Message {msg_id} not found.")
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
@@ -17,7 +17,7 @@ from rich.table import Table
from aipass.devpulse.apps.handlers.feedback.storage import load_inbox, save_inbox
from aipass.cli.apps.modules import err_console
from aipass.cli.apps.modules import err_console, error
from aipass.devpulse.apps.handlers.json import json_handler
console = err_console
@@ -67,7 +67,7 @@ def view_message(msg_id: str) -> None:
msg = _find_message(messages, msg_id)
if msg is None:
console.print(f"[red]Message {msg_id} not found.[/red]")
error(f"Message {msg_id} not found.")
return
# Mark as read
@@ -105,7 +105,7 @@ def clear_message(msg_id: str) -> None:
msg = _find_message(messages, msg_id)
if msg is None:
console.print(f"[red]Message {msg_id} not found.[/red]")
error(f"Message {msg_id} not found.")
return
was_unread = not msg.get("read")
@@ -0,0 +1,7 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: Owner-capability guard handlers package
# Version: 1.0.0
# Created: 2026-07-10
# Modified: 2026-07-10
# =============================================
@@ -0,0 +1,115 @@
# =================== AIPass ====================
# Name: guard.py
# Description: Owner-capability caller guard for devpulse owner-only tools
# Version: 1.0.0
# Created: 2026-07-10
# Modified: 2026-07-10
# =============================================
"""
Owner-capability guard for devpulse's owner-only tools.
watchdog and feedback's mailbox-management verbs are the project OWNER's
tools. The catch: drone runs a routed module with ``cwd=<branch_path>`` (see
drone router_handler), so the module's own ``Path.cwd()`` is ALWAYS the
devpulse tree and can't identify who called. The real caller lives in the env
drone sets — ``AIPASS_CALLER_BRANCH`` / ``AIPASS_CALLER_CWD``. This resolves
that caller and checks it against the sealed-registry owner via ``is_owner``.
Portable by construction: ``is_owner`` reads each project's OWN sealed
registry, so "owner" is devpulse in AIPass and whoever owns elsewhere (e.g.
@vera in Vera Studio) — no hardcoded name, no per-project scaffolding.
Fail-safe: if the owner resolver is unavailable (import fails, or a project
has no sealed owner yet — an old/partial install), it falls back to the legacy
devpulse-path heuristic so existing installs never hard-break. Concretely #681.
Returns a plain bool — the calling MODULE owns user-facing output (handlers
don't print). Denials are audit-logged here.
"""
import os
from pathlib import Path
from aipass.prax import logger
from aipass.devpulse.apps.handlers.json import json_handler
def _resolve_caller() -> tuple[str, Path]:
"""Resolve ``(caller_email, caller_cwd)`` from the env drone sets.
``caller_email`` is ``@<branch>`` — a branch's address is ``@`` + its
directory name (matches ai_mail's identity resolution). Prefers the
``AIPASS_CALLER_BRANCH`` env var; otherwise walks up ``AIPASS_CALLER_CWD``
for a ``.trinity/passport.json`` and uses that directory's name. Falls back
to the process cwd when no caller env is set (direct, non-drone invocation).
Returns:
tuple: (caller_email_or_empty, caller_cwd_path)
"""
caller_cwd_env = os.environ.get("AIPASS_CALLER_CWD", "")
caller_cwd = Path(caller_cwd_env) if caller_cwd_env else Path.cwd()
branch = os.environ.get("AIPASS_CALLER_BRANCH", "")
if not branch:
for candidate in [caller_cwd, *caller_cwd.parents]:
if (candidate / ".trinity" / "passport.json").exists():
branch = candidate.name
break
email = f"@{branch.lstrip('@').lower()}" if branch else ""
return email, caller_cwd
def _legacy_devpulse_heuristic(caller_cwd: Path) -> bool:
"""Pre-owner behavior: allow only a caller standing in the devpulse tree.
Used solely as the fail-safe when the owner resolver can't decide, so
existing AIPass installs keep working before the sealed owner is present.
"""
return caller_cwd.name == "devpulse" or any(p.name == "devpulse" for p in caller_cwd.parents)
def _owner_decision(email: str, caller_cwd: Path) -> bool:
"""Decide whether ``email`` is the owner of the project at ``caller_cwd``.
Owner check runs against the caller's OWN project registry (start_path =
caller_cwd), so cross-project calls resolve the caller's owner, not
AIPass's. Falls back to the legacy heuristic when the resolver is
unavailable or the project has no sealed owner yet.
"""
try:
# is_owner is the frozen shared owner-capability contract (spawn is its
# sole home; no modules re-export). Lazy import keeps cold start fast and
# enables the fail-safe fallback below.
from aipass.spawn.apps.handlers.registry import get_owner, is_owner
except ImportError as exc:
logger.warning("[owner_guard] owner resolver unavailable (%s) — legacy heuristic", exc)
return _legacy_devpulse_heuristic(caller_cwd)
if get_owner(start_path=caller_cwd) is None:
# No sealed owner in this project -> resolver can't decide -> legacy path check.
logger.info("[owner_guard] no sealed owner at %s — legacy heuristic", caller_cwd)
return _legacy_devpulse_heuristic(caller_cwd)
return bool(email) and is_owner(email, start_path=caller_cwd)
def guard_owner_caller(tool: str) -> bool:
"""Gate an owner-only tool.
Args:
tool: Name of the calling tool (e.g. 'watchdog', 'feedback') for the
audit line. The caller is responsible for any user-facing message.
Returns:
bool: True to allow the call; False (after audit-logging the denial) to
reject a non-owner caller.
"""
email, caller_cwd = _resolve_caller()
if _owner_decision(email, caller_cwd):
return True
json_handler.log_operation("owner_guard_denied", {"tool": tool, "caller": email or "unknown"})
logger.info("[owner_guard] %s denied non-owner caller=%s", tool, email or "unknown")
return False
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: agent.py
# Description: Watchdog Agent Handler — block until dispatched agent exits
# Version: 1.0.0
# Version: 1.1.0
# Created: 2026-04-14
# Modified: 2026-04-14
# Modified: 2026-07-10
# =============================================
# Signal choice: ai_mail dispatch lock file polling.
@@ -44,6 +44,19 @@ def _stderr(msg: str) -> None:
sys.stderr.flush()
def _stdout_event(msg: str) -> None:
"""Write one flushed event line to stdout so a Monitor-tool wrapper turns it
into a live notification to devpulse.
The Monitor tool treats each stdout line as an event but only captures stderr
to a file (never surfaced). So mid-watch signals a caller must ACT on — a stall
or a possibly-hung tool — go here, while the verbose debug trail stays on
_stderr + logger. (#634 part 2.)
"""
sys.stdout.write(msg + "\n")
sys.stdout.flush()
def _find_repo_root(start: Path | None = None) -> Path | None:
"""Walk upward looking for AIPASS_REGISTRY.json. Returns None if not found."""
cur = (start or Path.cwd()).resolve()
@@ -231,6 +244,87 @@ def _has_jsonl_activity(projects_dir: Path, baseline: dict) -> bool:
return False
def _newest_jsonl(projects_dir: Path) -> Path | None:
"""Return the most-recently-modified .jsonl in projects_dir, or None."""
if not projects_dir.exists():
return None
try:
files = list(projects_dir.glob("*.jsonl"))
except OSError as exc:
logger.info("[watchdog.agent] newest jsonl glob failed: %s", exc)
return None
newest: Path | None = None
newest_mtime = -1.0
for f in files:
try:
mtime = f.stat().st_mtime
except OSError as exc:
logger.info("[watchdog.agent] newest jsonl stat failed for %s: %s", f.name, exc)
continue
if mtime > newest_mtime:
newest_mtime = mtime
newest = f
return newest
def _tail_last_line(path: Path, max_bytes: int = 1_000_000) -> str | None:
"""Read the last non-blank newline-delimited line of a file via a bounded tail
read. Reads at most ``max_bytes`` from the end so a multi-MB transcript stays
cheap; a single line longer than that decodes partially and simply fails to
parse downstream (→ treated as no in-flight tool)."""
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
chunk = fh.read()
except OSError as exc:
logger.info("[watchdog.agent] tail read failed for %s: %s", path.name, exc)
return None
if not chunk:
return None
text = chunk.decode("utf-8", errors="replace")
lines = [ln for ln in text.splitlines() if ln.strip()]
return lines[-1] if lines else None
def _last_entry_is_inflight_tool(projects_dir: Path) -> bool:
"""True if the newest JSONL's last event is an assistant message dispatching a
tool call — an in-flight ``tool_use`` awaiting its result.
While a tool runs (a big Read, a long Bash, heavy compute) the agent writes NO
new JSONL lines, so size-growth alone misreads that span as idle and false-fires
STALLED (#634 part 1). The last line being an assistant ``tool_use`` is the
precise signal that the agent is actively working, not stuck.
Best-effort: any read/parse/shape surprise returns False, degrading to the
size-based liveness check so the stall detector never crashes on a format drift.
"""
newest = _newest_jsonl(projects_dir)
if newest is None:
return False
last_line = _tail_last_line(newest)
if not last_line:
return False
try:
entry = json.loads(last_line)
except (json.JSONDecodeError, ValueError) as exc:
logger.info("[watchdog.agent] last jsonl entry unparseable in %s: %s", newest.name, exc)
return False
if not isinstance(entry, dict):
return False
# Schemas vary: role/content may sit under "message" or at the top level.
message = entry.get("message")
if not isinstance(message, dict):
message = entry
if message.get("role") != "assistant":
return False
content = message.get("content")
if not isinstance(content, list):
return False
return any(isinstance(block, dict) and block.get("type") == "tool_use" for block in content)
def _read_lock(lock_file: Path) -> dict | None:
"""Read lock file, return dict or None on miss/error."""
if not lock_file.exists():
@@ -316,6 +410,98 @@ def _classify_exit(
return ("completed_silent", reason, 0)
class StallTracker:
"""Per-watch JSONL liveness/stall state — one ``observe()`` call per poll tick.
Liveness signal = new JSONL lines (size growth) OR an in-flight tool call. A
long single tool call (big Read, long Bash, heavy compute) writes no new JSONL
lines while it runs but leaves an assistant ``tool_use`` as the last entry, so
it counts as activity instead of false-firing STALLED (#634 part 1).
Actionable signals — stall, long-running tool, resumed — go to stdout via
``_stdout_event`` so a Monitor-tool wrapper surfaces them to devpulse live
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
"""
STALL_THRESHOLD = 120.0
# A single tool call held in-flight this long is surfaced as a soft advisory
# (heavy op or a hung tool). Below the 600s default timeout so long watches
# get a mid-flight heads-up instead of waiting on the timeout.
LONG_TOOL_THRESHOLD = 300.0
def __init__(self, agent_id: str, jsonl_dir: Path, baseline: dict, now: float, pid: object) -> None:
self.agent_id = agent_id
self.jsonl_dir = jsonl_dir
self.baseline = baseline
self.pid = pid
self.last_activity_at = now
self.in_flight_since: float | None = None
self.stall_reported = False
self.long_tool_reported = False
def observe(self, now: float) -> None:
"""Evaluate one poll tick: reset on activity, else report a stall past threshold."""
size_grew = _has_jsonl_activity(self.jsonl_dir, self.baseline)
inflight = False if size_grew else _last_entry_is_inflight_tool(self.jsonl_dir)
if size_grew or inflight:
self._mark_active(now, size_grew, inflight)
elif not self.stall_reported and (now - self.last_activity_at) >= self.STALL_THRESHOLD:
self._report_stall(now)
def _mark_active(self, now: float, size_grew: bool, inflight: bool) -> None:
if size_grew:
self.baseline = _snapshot_jsonl_sizes(self.jsonl_dir)
self.last_activity_at = now
if self.stall_reported:
_stdout_event(f"[watchdog.resumed] {self.agent_id}: JSONL activity resumed — stall cleared")
_stderr(f"[watchdog.agent] {self.agent_id}: activity resumed")
logger.info("[watchdog.agent] activity resumed agent_id=%s", self.agent_id)
self.stall_reported = False
if inflight:
self._track_inflight(now)
else:
self.in_flight_since = None
self.long_tool_reported = False
def _track_inflight(self, now: float) -> None:
if self.in_flight_since is None:
self.in_flight_since = now
inflight_secs = int(now - self.in_flight_since)
if self.long_tool_reported or inflight_secs < self.LONG_TOOL_THRESHOLD:
return
_stdout_event(
f"[watchdog.longtool] {self.agent_id}: one tool call running {inflight_secs}s "
f"(PID {self.pid} alive) — likely a heavy op, but may be a hung tool. "
f"Check, or kill+resume if stuck."
)
logger.info(
"[watchdog.agent] long-running tool agent_id=%s inflight=%ss pid=%s",
self.agent_id,
inflight_secs,
self.pid,
)
self.long_tool_reported = True
def _report_stall(self, now: float) -> None:
idle_secs = int(now - self.last_activity_at)
_stdout_event(
f"[watchdog.stall] {self.agent_id}: STALLED — no JSONL activity for {idle_secs}s "
f"(PID {self.pid} alive, no in-flight tool call). Agent may be stuck — "
f"check, or kill+resume."
)
_stderr(
f"[watchdog.agent] {self.agent_id}: STALLED — no JSONL activity for {idle_secs}s "
f"(PID {self.pid} still alive)"
)
logger.info(
"[watchdog.agent] stall detected agent_id=%s idle=%ss pid=%s",
self.agent_id,
idle_secs,
self.pid,
)
self.stall_reported = True
def watch_agent(
agent_id: str,
timeout_seconds: int = 600,
@@ -383,10 +569,7 @@ def watch_agent(
_stderr(f"[watchdog.agent] {agent_id}: lock present, monitor PID={initial_pid}")
jsonl_dir = _get_jsonl_projects_dir(branch_path)
jsonl_baseline = _snapshot_jsonl_sizes(jsonl_dir)
last_activity_at = time.monotonic()
stall_reported = False
stall_threshold = 120.0
tracker = StallTracker(agent_id, jsonl_dir, _snapshot_jsonl_sizes(jsonl_dir), time.monotonic(), initial_pid)
while True:
elapsed = time.monotonic() - started_at
@@ -441,26 +624,7 @@ def watch_agent(
"handle": handle,
}
if _has_jsonl_activity(jsonl_dir, jsonl_baseline):
jsonl_baseline = _snapshot_jsonl_sizes(jsonl_dir)
last_activity_at = time.monotonic()
if stall_reported:
_stderr(f"[watchdog.agent] {agent_id}: activity resumed")
logger.info("[watchdog.agent] activity resumed agent_id=%s", agent_id)
stall_reported = False
elif not stall_reported and (time.monotonic() - last_activity_at) >= stall_threshold:
idle_secs = int(time.monotonic() - last_activity_at)
_stderr(
f"[watchdog.agent] {agent_id}: STALLED — no JSONL activity for {idle_secs}s "
f"(PID {initial_pid} still alive)"
)
logger.info(
"[watchdog.agent] stall detected agent_id=%s idle=%ss pid=%s",
agent_id,
idle_secs,
initial_pid,
)
stall_reported = True
tracker.observe(time.monotonic())
time.sleep(poll_interval)
finally:
@@ -173,10 +173,45 @@ def _is_zombie_linux(pid: int) -> bool:
return False
def _pid_alive_windows(pid: int) -> bool:
"""Windows-safe liveness via OpenProcess + GetExitCodeProcess (mirrors agent.py)."""
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
return exit_code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
def is_pid_alive(pid: int) -> bool:
"""Return True if the process exists and is not a zombie."""
"""Return True if the process exists and is not a zombie. Windows-safe."""
if not isinstance(pid, int) or pid <= 0:
return False
if sys.platform == "win32":
# os.kill(pid, 0) TERMINATES the target on Windows — use OpenProcess.
try:
return _pid_alive_windows(pid)
except Exception as exc:
logger.info("[watchdog.registry] PID %s Windows check failed (assuming alive): %s", pid, exc)
return True
try:
os.kill(pid, 0)
except ProcessLookupError as exc:
+36 -14
View File
@@ -27,7 +27,7 @@ from aipass.devpulse.apps.handlers.feedback.compose import (
)
from aipass.prax import logger
from aipass.cli.apps.modules import err_console
from aipass.cli.apps.modules import err_console, error, warning
from aipass.devpulse.apps.handlers.json import json_handler
console = err_console
@@ -47,6 +47,21 @@ HELP_TEXT = """\
"""
def _guard_caller() -> bool:
"""Owner-only gate for mailbox reads/management (see handlers.owner.guard).
The mailbox belongs to the project owner. `send` and `--help` stay open
(send is the inbound channel any agent uses to drop feedback here); every
other verb reads or mutates the owner's mail and is owner-gated. #681.
"""
from aipass.devpulse.apps.handlers.owner.guard import guard_owner_caller
if guard_owner_caller("feedback"):
return True
warning("feedback mailbox management is owner-only — refusing non-owner call")
return False
def print_introspection() -> None:
"""Display module introspection info."""
console.print()
@@ -73,6 +88,20 @@ def handle_command(command: str, args: list[str]) -> bool:
if command != "feedback":
return False
# Open verbs (no owner gate): help + `send`. `send` is the inbound channel
# any agent uses to drop feedback into the owner's mailbox. Everything else
# reads or manages that mailbox -> owner-only (#681).
if args and args[0] in ("--help", "-h", "help"):
console.print(HELP_TEXT)
return True
if args and args[0] == "send":
json_handler.log_operation("feedback_command", {"subcommand": "send"})
return _handle_send(args[1:])
if not _guard_caller():
return True
if not args:
print_introspection()
summary = get_summary()
@@ -83,36 +112,29 @@ def handle_command(command: str, args: list[str]) -> bool:
sub_args = args[1:]
json_handler.log_operation("feedback_command", {"subcommand": subcommand})
if subcommand in ("--help", "-h", "help"):
console.print(HELP_TEXT)
return True
if subcommand == "inbox":
list_messages()
return True
if subcommand == "view":
if not sub_args:
console.print("[red]Usage: feedback view <id>[/red]")
error("Usage: feedback view <id>")
return True
view_message(sub_args[0])
return True
if subcommand == "reply":
if len(sub_args) < 2:
console.print('[red]Usage: feedback reply <id> "message"[/red]')
error('Usage: feedback reply <id> "message"')
return True
msg_id = sub_args[0]
body = " ".join(sub_args[1:])
reply_to(msg_id, body)
return True
if subcommand == "send":
return _handle_send(sub_args)
if subcommand == "clear":
if not sub_args:
logger.error("Usage: feedback clear <id> | feedback clear --all")
error("Usage: feedback clear <id> | feedback clear --all")
return True
if sub_args[0] == "--all":
clear_all_read()
@@ -120,8 +142,8 @@ def handle_command(command: str, args: list[str]) -> bool:
clear_message(sub_args[0])
return True
console.print(f"[red]Unknown feedback subcommand: {subcommand}[/red]")
console.print("Use [bold]feedback --help[/bold] for usage.")
logger.warning("[feedback] unknown subcommand: %s", subcommand)
error(f"Unknown feedback subcommand: {subcommand}", suggestion="Use 'feedback --help' for usage")
return True
@@ -138,7 +160,7 @@ def _handle_send(args: list[str]) -> bool:
bool: Always True (command was handled).
"""
if len(args) < 2:
console.print('[red]Usage: feedback send "subject" "body"[/red]')
error('Usage: feedback send "subject" "body"')
console.print("[dim]Tip: from_branch is auto-detected or pass as first arg.[/dim]")
return True
+20 -7
View File
@@ -25,11 +25,10 @@ See FPLAN-0186 for the build plan and DPLAN-0130 for the design record.
"""
import importlib
from pathlib import Path
from typing import List
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, error, warning
from aipass.cli.apps.modules import console, err_console, error, warning
from aipass.devpulse.apps.handlers.json import json_handler
_VALID_SUBCOMMANDS = ["agent", "timer", "schedule", "status", "cancel", "list"]
@@ -113,11 +112,18 @@ def print_introspection() -> None:
def _guard_caller() -> bool:
"""Reject cross-branch invocation. Devpulse-only tool."""
cwd = Path.cwd()
if cwd.name == "devpulse" or any(p.name == "devpulse" for p in cwd.parents):
"""Reject non-owner invocation. Owner-only tool.
Gates on the PROJECT OWNER (sealed registry) via the shared owner guard, so
it works across projects — not a hardcoded 'devpulse' name. (Drone runs a
routed module with cwd=<branch_path>, so Path.cwd() can't identify the real
caller; the guard reads the AIPASS_CALLER_* env drone sets.) #681.
"""
from aipass.devpulse.apps.handlers.owner.guard import guard_owner_caller
if guard_owner_caller("watchdog"):
return True
warning("watchdog is a devpulse-only module — refusing cross-branch call")
warning("watchdog is an owner-only module — refusing non-owner call")
return False
@@ -325,7 +331,14 @@ def _handle_agent(sub_args: List[str]) -> bool:
error("Usage: watchdog agent <branch> [--timeout SECONDS]")
return True
error("WATCHDOG: Must be invoked via Monitor tool, never run_in_background")
# Reminder, not an error: this call blocks until the agent exits, so it must
# run via the Monitor tool (not run_in_background) for the wake to fire on
# completion. MUST go to stderr: the Monitor tool treats every STDOUT line
# as a wake event, so a stdout banner fires a spurious wake at arm time —
# stdout carries completion/stall events only (#634 contract; VERA feedback
# 315c005e). error() is also wrong — ❌ trips the exit-code fail-flag on an
# otherwise-successful watch (#661 output_routing).
err_console.print("[dim]watchdog agent: invoke via Monitor tool, not run_in_background[/dim]")
timeout = _DEFAULT_AGENT_TIMEOUT
positional: List[str] = []
@@ -1,7 +1,10 @@
# META
# module: devpulse.feedback
# description: Tests for feedback module command routing
# END META
# =================== AIPass ====================
# Name: test_feedback_module.py
# Description: Tests for feedback module command routing
# Version: 1.0.0
# Created: 2026-04-11
# Modified: 2026-07-10
# =============================================
"""Tests for feedback module — command routing via handle_command()."""
@@ -13,6 +16,13 @@ from aipass.devpulse.apps.handlers.feedback import storage
from aipass.devpulse.apps.modules import feedback as feedback_module
@pytest.fixture(autouse=True)
def _bypass_caller_guard():
"""Force _guard_caller to pass so routing tests don't depend on owner env."""
with patch.object(feedback_module, "_guard_caller", return_value=True):
yield
@pytest.fixture
def mock_feedback_dir(tmp_path):
"""Patch FEEDBACK_DIR to use tmp_path for isolation."""
@@ -183,6 +193,35 @@ class TestCommandRouting:
assert result is True # Handled (shows error + hint)
class TestOwnerGate:
"""Owner gate wraps mailbox management; send + help stay open (#681)."""
def test_management_blocked_for_non_owner(self, populated_inbox):
"""A denied guard blocks a management verb — view does not mark read."""
with patch.object(feedback_module, "_guard_caller", return_value=False):
result = feedback_module.handle_command("feedback", ["view", "aaa11111"])
assert result is True # command still "handled" (clean refusal)
data = storage.load_inbox()
msg = next(m for m in data["messages"] if m["id"] == "aaa11111")
assert msg["read"] is False # action was gated out
def test_send_open_for_non_owner(self, empty_inbox):
"""send bypasses the owner gate — any agent can drop feedback."""
with patch.object(feedback_module, "_guard_caller", return_value=False):
result = feedback_module.handle_command("feedback", ["send", "seedgo", "Bug report", "Found an issue"])
assert result is True
data = storage.load_inbox()
assert data["total_messages"] == 1 # send bypassed the gate
def test_help_open_for_non_owner(self, empty_inbox):
"""--help bypasses the owner gate."""
with patch.object(feedback_module, "_guard_caller", return_value=False):
result = feedback_module.handle_command("feedback", ["--help"])
assert result is True
class TestHandleCommandHasCorrectSignature:
"""Verify handle_command meets auto-discovery requirements."""

Some files were not shown because too many files have changed in this diff Show More