edited="\$(read ...)" was always empty because read writes to a variable
name, not stdout. Changed to: read -e -p "Command: " -i "\$flat" edited
which stores directly into \$edited.
read -e -i only handles single-line text. Multi-line commands (docker
install etc) broke it. Now flattens newlines to spaces before pre-filling
the readline buffer. User sees a single editable line.
- _inject_command tries: xclip/wl-copy (clipboard) -> xdotool (typing) -> tmux -> history
- Clipboard is primary: user pastes with Ctrl+Shift+V
- preinstall.sh: add xdotool and xclip to PACKAGES
- e: xdotool type (X11/Wayland) -> tmux send-keys -> history fallback
- Command appears on active terminal line for editing before Enter
- Y/Enter: execute, n: add to history
- _extract_commands() parses bash/sh/shell fenced code blocks
- _prompt_run_command() prompts [Y/n] via /dev/tty after AI response
- Y/Enter: execute via run helper (respects DRY_RUN)
- n: command added to history (press up-arrow to recall, edit, run)
- Integrated in both cmd_ask() and cmd_chat()
- Skipped when output is piped/redirected
Each provider now has its own key: AI_GEMINI_API_KEY and OPENROUTER_API_KEY.
No more shared AI_API_KEY that caused cross-provider key leakage (gemini
getting openrouter key → 400 error). resolve_key() sets AI_API_KEY internally
from the active provider's key for adapter use. Config UI shows both keys.
- POS_CONFIG header: replace | with 'or' in AI_PROVIDER description
(bare | was parsed as field separator, splitting one entry into two)
- render_markdown: check /dev/tty as fallback when shell hook redirects
stdout through tee (breaks [ -t 1 ] but /dev/tty stays writable)
--last now compares mtime of pos dispatcher logs vs captured output
(last_cmd_output) and uses whichever is newer, instead of always
preferring pos logs even when they are hours old.
New bin/pos-docker-stack: docker ps output grouped by Docker Compose
project (stack). Each stack is a sorted section (name, status, ports);
containers without a compose project land in a Standalone group at the
end. Running only by default, -a|--all includes stopped/exited. Status
colored on a terminal; summary line 'Stacks: N containers: N
standalone: N'; exit 0 when empty.
Data via docker ps --format with \x1f delimiters (project label
com.docker.compose.project from compose v2); parsed with awk -F'\x1f'
+ IFS=$'\x1f' read — tab/pipe delimiters are IFS whitespace or appear
in values (DEV.md:213). Dash padding via sed, not tr (multi-byte).
Deps guard (docker) before --help; no stdin.
Docs: POS.md docker row + detail, howto/docker.md table + section,
bin/pos usage EXAMPLES, AGENT_Context Common Tasks row. Verified:
stub suite 23/23, live daemon runs, dispatch, make gen && make check,
make lint 0 FAIL / 0 WARN.
usb_detect offered a Ventoy stick's 32M VTOYEFI ESP as a sync/backup
target: with the data partition unmounted it was the only mounted
candidate, and cp died mid-copy with 'No space left on device'.
Detection now reads FSTYPE/PARTTYPENAME and drops EFI system
partitions from both the mounted list and the mount-offer list;
USB_MOUNTED entries carry mp|label|size|model|fs and usb_pick_root
shows that in the single-stick confirm and the multi-stick/partition
picker (USB_ROOT stays a bare mountpoint). pos-media-sync pre-flights
the exact payload size vs df free space (err, or warn under --dry-run)
before any copy.
GNU find (default -P) does not descend a command-line symlink to a
directory, so `find $HOME/Music ...` returned zero files and the tool
reported '0 added, 0 updated, 0 unchanged' without creating the target.
Switch to `find -H` (follows only command-line symlinks; inner-symlink
semantics unchanged).
x-systemd.automount in a unit file's Options= is only honored when read
from /etc/fstab, so persisted mounts never armed an automount; the unit was
also static (no [Install]) so enable was a no-op. Result: after a reboot the
share sat inactive and the disk never mounted on access.
persist now writes both <name>.mount and <name>.automount units (the
automount with WantedBy=multi-user.target) and enables/starts the automount;
unpersist stops/disables/removes both units.
persist uses x-systemd.automount, so the CIFS share is never actually
mounted until first access and findmnt -t cifs stays empty — list reported
'No SMB mounts' despite configured persistent units. list now also scans
$UNIT_DIR/*.mount for Type=cifs and reports them under 'Persistent
(automount)' (where, what). Empty output still prints 'No SMB mounts'.
Once the archive verifies, USB detection runs (so a stick plugged in while
the backup ran is found): mounted removable storage is auto-detected via
lsblk -J + a recursive jq filter (rm, mounted, type part|disk — JSON makes
spacey mountpoints safe), or BACKUP_USB_ROOT pins a fixed stick and skips
detection. None mounted → one re-scan prompt ('s' skips, EOF from cron
skips silently, rc stays 0); one stick → y/N confirm; several → numbered
pick (0 = skip). The copy lands in <usb>/backups/ (mkdir -p, chmod 600
best-effort — a vfat chmod failure warns, never fails the copy) and the
transfer is proven 100% by sha256 source-vs-copy before any success is
announced; a mismatch warns with both hashes, notifies 'USB copy FAILED',
and exits 1. The ERR trap is re-armed before the USB phase so a copy
failure no longer notifies 'Backup FAILED'. Docs: usage() Environment,
POS.md backup row, howto/system.md (USB section + env table + mismatch
troubleshooting), DEV.md system.env list. Stub suite
(/tmp/opencode/backup-test, HOME-isolated, sudo/gpg/lsblk/sender stubs,
corrupting-cp + vfat-chmod overrides, per-test lsblk JSON fixtures):
40/40 green.
share now checks both common NT_STATUS_ACCESS_DENIED causes before writing the
config (warnings only — the share is still applied):
- --users entries missing from the Samba passdb (pdbedit -L) get a warning
pointing at 'pos share smb-server adduser <user>' — valid users = <u> with
no Samba password previously failed for clients with no clue why.
- every ancestor of the share path is checked for other:+x traversal (sticky
dirs like /tmp count as traversable); a 700 home dir under the share path
now warns with 'chmod o+x <dir>'.
Docs: howto/share.md SMB section + NT_STATUS_ACCESS_DENIED troubleshooting.
Verified with a stub-PATH suite (pdbedit/systemctl/smbcontrol/testparm stubs,
SMB_CONF seam): 16/16 green.
The single-timer threshold monitor generalizes into a scheduler: each job is a
chmod-600 file in schedule.d/<name>.env (INTERVAL 5m..59m/1h..23h/hourly/daily/
weekly/OnCalendar=..., NOTIFY policy, optional MSG, RULE for threshold,
COMMAND = literal rest of line) with its own systemd user timer pair
(pos-schedule-<name>.timer + oneshot .service, Persistent, reconciled on
enable/disable — orphan units + the legacy pos-event-trigger timer
auto-removed). Policies: always (full output every run), onchange (diff vs
last run, first run sends), onerror (non-zero exit or empty output),
threshold (old event-trigger behavior: first numeric vs RULE, alert on
false→true + recovery, per-job firing state), never (silent side-effect jobs).
run [name|all], list, config (interactive add/edit/remove/enable/disable with
validation), enable/disable [name|all], status, migrate (converts legacy
event.env rules → rule-N.env threshold jobs, verbatim LHS as COMMAND, adopts
the legacy timer's OnCalendar or 5m, removes the old timer). Per-run logs +
state in ~/.local/share/linux_post_install/schedule/{logs,state}/.
config/event.env + event-rules.template → config/schedule.d/ starter jobs
(nvme-health, cpu-temp, disk-root, silent log-cleanup); postinstall installs
them no-clobber into an empty schedule.d/ (legacy event.env users get a
migrate hint instead). bin/pos EXAMPLES + INTERACTIVE_CMDS
(system-schedule config) updated; install.sh ships the renamed tool+lib.
Give a dead single-file HTTP/FTP download a fresh URL: same dir + file name
(--continue=true resumes the partial), forgets the old source from
download.retry, verifies the new link (a dead replacement is diagnosed and
marked permanent instead of silently looping). status flags downloads needing
this with a 'needs fresh link' line. Torrents/active/multi-file rejected with
hints; --dir/--split/--tmux supported.
restart <gid>: re-queue from history — torrents via rebuilt magnet
(urn:btih: + &tr= trackers), HTTP via original URIs keeping dir/out;
--continue=true resumes partials, complete files verify instantly.
retry <gid|all>: waits out internet outages (NET_PROBE seam,
--interval/--max-wait), re-queues and re-verifies; aria2 error 3 = real
problem → diagnosed + marked permanent (url:/bt: ids in download.retry,
skipped by retry all, manual restart overrides); --once/--quiet for the
healer timer.
Healer: pos-aria2-retry.{service,timer} user units — arms on download
start (add/torrent/metalink/restart), disables when nothing left.
watch <gid> auto-restarts after an outage.
Fixes from stub-suite review: ensure_healer missing from submit paths;
RESTART_NAME lost across do_restart subshell (download_name helper);
restart exited 1 (tmux test as last statement).
Stub harness (/tmp/opencode/dl-test) 119/119 green; make gen && make check green.
Docs: POS.md rows, howto/network.md outage recipe, SYSTEMD.md user units.
Persistent aria2c as a systemd user service (pos-aria2.service, enable --now,
linger warning) on localhost:6800 with a generated RPC secret in
~/.config/linux_post_install/download.env (chmod 600, env override). 18
commands: start/stop/status, add/torrent/metalink (auto-start, --tmux live
view), list/info/files/peers, pause/resume/remove/purge/move, limit/set,
watch (2s live repoll). JSON built via jq -nc --arg, never string
interpolation. Deps: aria2 in preinstall PACKAGES, aria2c/jq/curl guards
before --help. Docs: POS.md + howto/network.md + indices + Common Tasks row.
pos communication matrix sender: send (plain/--markdown/--room), login
(password->access token via m.login.password), test. Implements the
lib/notify.sh sender contract, so NOTIFY_PLATFORM=telegram,matrix now
fans out for real; matrix.env config scope registered for pos config.
pos communication matrix listener: systemd user daemon long-polling
/sync (since token, compact m.room.message filter); reacts to own user's
messages (/ and ! both resolve), threaded m.in_reply_to replies, @quiet
marker, ai bridge with per-room session, interactive editor. Added to
INTERACTIVE_CMDS.
Docs: POS.md, howto/communication.md, HOWTO.md, usage EXAMPLES.
Verified against a mock homeserver (send shape, login, owner filter,
replies, exit codes, editor). make gen && make check green.
Diagnosis aid: when a rule evaluates but doesn't fire, run now prints
'OK: <cmd> = <value> (no change)' so a rule that silently never crosses
its threshold (e.g. wrong sensor in the check command) is visible
instead of just 'evaluated N rule(s)'.