# pi-agent-tick

Agent Tick support for [Pi](https://github.com/earendil-works/pi-coding-agent): remote human decisions, approval gates, and lifecycle status updates for coding-agent sessions.

This package replaces the generic `ask_user` experience with an Agent Tick-first tool named `agent_tick_ask_user`, while keeping a local Pi prompt and a compatibility `ask_user` alias.

## What is Agent Tick?

Agent Tick is a least-permission approval layer for agent work. It lets an agent send Steering, a Sanction, or a Status Update to a user outside the terminal — for example in the Native App or Personal Console — then wait for the user's response before continuing when a response is required.

In this package, Agent Tick is used only as a decision/approval/status layer. It does **not** execute shell commands or tools remotely.

## How answering works

When Agent Tick config is available, each `agent_tick_ask_user` prompt appears in **two places at the same time**:

1. the local Pi `ask_user` TUI on the machine running the agent
2. the Agent Tick app/web UI with the same question and choices

Answer from whichever place is convenient for that request. The first valid answer wins, and the other pending prompt is resolved. That means you can answer one decision from the terminal, the next from your phone, and switch back and forth per request.

## What this package does

- Registers `agent_tick_ask_user`, a Pi tool for asking exactly one focused question.
- Shows the local Pi `ask_user` TUI and the Agent Tick app/web request simultaneously when `agent-tick login` config is available.
- Uses whichever side answers first, then resolves the other pending prompt.
- Sends single-select, multi-select, comments, and recommended-option flags to Agent Tick.
- Keeps a local overlay/inline Pi prompt so sessions still work without Agent Tick.
- Optionally gates risky `bash` tool calls with local and/or Agent Tick approval.
- Optionally sends lifecycle status updates such as `working`, `waiting`, `blocked`, `done`, or `failed`.
- Bundles an `agent-tick-decision-gate` skill for high-impact or ambiguous decisions.

## Install

```bash
pi install npm:@self-deprecated/pi-agent-tick
```

For local development or unreleased changes:

```bash
pi install git:github.com/self-deprecated/pi-agent-tick
```

Run Agent Tick login to enable remote phone/web mirroring:

```bash
agent-tick login
```

If no Agent Tick config is found, the same tool still opens the local Pi prompt. If config is found, you get both the local prompt and the remote Agent Tick request for each question.

## Quick example

Ask Pi to use `agent_tick_ask_user` at a decision boundary. A good call separates previous context, the one focused question, and any expanded details:

```json
{
  "previousContext": {
    "findings": ["The branch has passed tests locally."],
    "constraints": ["Staging is reversible; production affects customers immediately."]
  },
  "question": "Which deploy target should I use?",
  "description": "Choose the environment for this validation pass.",
  "options": [
    { "title": "staging", "description": "Safer, reversible validation path", "flags": ["favorite", "safest"] },
    { "title": "production", "description": "Customer-facing deploy" }
  ],
  "allowFreeform": false,
  "choiceInteractionMode": "select-then-submit"
}
```

## Tool names

Canonical tool:

- `agent_tick_ask_user`

Compatibility alias:

- `ask_user`

Installing this together with the original `pi-ask-user` package is not recommended because both provide an `ask_user` tool.

## Agent Tick configuration

Agent Tick auth/config follows the Agent Tick CLI:

1. `AGENT_TICK_CONFIG`, or `~/.config/agent-tick/config.json`
2. `AGENT_TICK_SERVER` and/or `AGENT_TICK_TOKEN` environment overrides

Useful environment variables:

| Variable | Purpose |
| --- | --- |
| `PI_AGENT_TICK_DISABLED=1` | Disable remote Agent Tick mirroring even when config exists. |
| `PI_AGENT_TICK_TIMEOUT_MS` | Remote wait timeout. Defaults to unlimited (`0`). |
| `PI_AGENT_TICK_CHOICE_INTERACTION_MODE` | `click-to-submit` or `select-then-submit`. |
| `PI_AGENT_TICK_OPTION_PLACEMENT` | `sticky-bottom` or `inline-after-content`. |
| `PI_AGENT_TICK_CONFIRM_BEFORE_SUBMIT` | `always`/`never` or boolean-ish values. |
| `AGENT_TICK_FEATURES_CONFIG` | Explicit feature config path. |
| `AGENT_TICK_SESSION_ID` | Optional externally supplied Session ID. If omitted, Agent Tick uses Pi's persisted chat/session ID when available. If no real Pi chat ID is available, it should omit explicit `sessionId` rather than generate a random fallback; Agent Tick groups best-effort by source metadata. |
| `AGENT_TICK_SESSION_TITLE` | Optional user-facing Session title/label. If omitted, Agent Tick may use the task summary for the current chat. Titles are labels only and do not group Activity. |

## Agent feature configuration

Feature config is separate from Agent Tick auth. Use the generic Agent Tick CLI surface:

```bash
agent-tick features show
agent-tick features tui
agent-tick features enable message-mirroring
agent-tick features disable heartbeat
```

`agent-tick features` and `agent-tick features tui` open an interactive selector: move with ↑/↓ or j/k, toggle with Space or Enter, press `s` to show JSON, and `q` to quit.

Pi currently consumes the shared feature config. It resolves in this order:

1. built-in defaults
2. global Agent Tick feature config: `~/.config/agent-tick/features.json`
3. project-local Agent Tick feature config: `.agent-tick/features.json`
4. explicit config path: `AGENT_TICK_FEATURES_CONFIG=/path/to/config.json`

Project-local config overrides global config. Rule maps are keyed by ID, so a local config can disable or change one global rule without copying every rule.

## Optional status updates

Status updates are best-effort and only send when Agent Tick config is present and status config is enabled. Use the Agent Tick CLI to inspect and change the shared feature config:

```bash
agent-tick features show
agent-tick features tui
agent-tick features enable message-mirroring
agent-tick features disable heartbeat
agent-tick features set status.heartbeat.intervalMs 15000
```

The built-in defaults send start, finish, and long-running heartbeat Status Updates; turn-end, shutdown, full message mirroring, structured Tool Activity, detailed tool disclosure, and Sanction approval gates are off until enabled.

```json
{
  "status": {
    "enabled": true,
    "hooks": {
      "before_agent_start": { "send": true, "state": "working", "message": "task-summary" },
      "turn_end": { "send": false, "state": "working", "message": "none" },
      "agent_end": { "send": true, "state": "waiting", "message": "Finished; waiting" },
      "session_shutdown": { "send": false, "state": "done", "message": "none" }
    },
    "heartbeat": { "enabled": true, "intervalMs": 285000, "message": "Still working" },
    "messageMirroring": { "enabled": false, "contentMode": "private", "sendAssistant": "final-only" },
    "toolActivity": {
      "enabled": false,
      "visibility": "off",
      "detailContentMode": "private",
      "maxDetailChars": 2000
    }
  }
}
```

`messageMirroring.includeToolUseTurns` only allows assistant text messages whose stop reason is tool-use; it does not mirror tool call arguments or tool results. Use `status.toolActivity.visibility` for structured Tool Activity: `off` sends nothing, `names` sends tool identifiers/events only, `summaries` adds safe summaries, and `details` sends redacted tool detail only as encrypted private content. Enable **Native App → Settings → General → Private encryption** before using `details`.

```json
{
  "status": {
    "toolActivity": {
      "enabled": true,
      "visibility": "summaries"
    }
  }
}
```

```json
{
  "status": {
    "toolActivity": {
      "enabled": true,
      "visibility": "details",
      "detailContentMode": "private",
      "maxDetailChars": 2000
    }
  }
}
```

## Optional bash approval gates

Sanctions are opt-in. By default there are no allow/deny rules and no command approval gate is applied.

Example: allow everything except matching risky commands, which require approval:

```json
{
  "sanctions": {
    "enabled": true,
    "policy": "allow-by-default",
    "tools": {
      "bash": {
        "deny": {
          "rm-rf": {
            "command": "rm",
            "argsRegex": "(^|\\s)-[A-Za-z]*r[A-Za-z]*f",
            "riskClass": "destructive-filesystem",
            "reason": "recursive forced remove"
          },
          "push": {
            "commandRegex": "^(git|jj)$",
            "argsRegex": "\\bpush\\b",
            "riskClass": "vcs-push",
            "reason": "VCS push"
          }
        }
      }
    }
  }
}
```

Example: require approval by default, but allow known-safe commands:

```json
{
  "sanctions": {
    "enabled": true,
    "policy": "approve-by-default",
    "tools": {
      "bash": {
        "allow": {
          "jj-status": { "command": "jj", "argsRegex": "^st(atus)?$" },
          "safe-tests": { "commandRegex": "^(bun|npm)$", "argsRegex": "\\btest\\b" }
        }
      }
    }
  }
}
```

If allow and deny rules both match, deny wins. Remote Sanction request bodies are redacted before disclosure. Agent Tick returns only the Sanction decision; Pi still owns local tool execution.

## `agent_tick_ask_user` parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `question` | `string` | required | The short, focused question to ask. Keep prior decisions and long setup out of this field. |
| `previousContext` | `{summary?, decisions?, findings?, constraints?, nextStep?}?` | — | Structured prior context rendered first, before the question. Prefer this for chained prompts. |
| `context` | `string?` | — | Legacy plain-text previous context. Rendered before the question; prefer `previousContext` for new calls. |
| `description` | `string?` | — | Expanded details explaining the current question. Rendered after the question. |
| `options` | `(string \| {title, description?, flags?})[]?` | `[]` | Multiple-choice options. Use `flags: ["favorite"]` for the recommended option. |
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode. |
| `allowFreeform` | `boolean?` | `true` | Add a freeform answer path. |
| `allowComment` | `boolean?` | `false` | Let the user attach optional extra context to a selection. |
| `displayMode` | `"overlay" \| "inline"?` | env or `"overlay"` | Local Pi rendering mode. |
| `overlayToggleKey` | `string?` | env or `"alt+o"` | Hide/show local overlay while it is open. Pass `"off"` to disable. |
| `commentToggleKey` | `string?` | env or `"ctrl+g"` | Toggle optional comment row. Pass `"off"` to disable. |
| `timeout` | `number?` | — | Auto-dismiss local UI and use the same remote wait timeout. |
| `choiceInteractionMode` | `"click-to-submit" \| "select-then-submit"?` | env or `"click-to-submit"` | Remote Agent Tick choice behavior. |
| `optionPlacement` | `"sticky-bottom" \| "inline-after-content"?` | env or `"inline-after-content"` | Remote Agent Tick option placement. |
| `confirmBeforeSubmit` | `boolean?` | env or `true` | Ask Agent Tick to confirm direct clickable submissions. |

### Prompt layout examples

Local prompts render in this order:

```text
Previous context
...prior decisions/findings, if provided...

Question
...one focused ask...

Details
...expanded explanation, if provided...
```

**Chained Grill Me-style decision**

```json
{
  "previousContext": {
    "decisions": ["Generated chapter files use stable key + title slugs."],
    "findings": ["Tasks 0050-0060 need a reviewed entity/alias registry."],
    "nextStep": "Choose the registry storage format."
  },
  "question": "How should the reviewed entity/alias registry be stored?",
  "description": "Pick the storage shape future review tasks will maintain.",
  "options": ["One YAML file per entity", "Single YAML file", "Markdown entity pages", "SQLite table only"]
}
```

**Short legacy-compatible follow-up**

```json
{
  "context": "The previous answer chose per-chapter markdown files.",
  "question": "Should aliases live with each chapter or in a shared registry?",
  "description": "This decides whether alias review diffs stay local to a chapter or centralized.",
  "options": ["Chapter-local aliases", "Shared registry"]
}
```

**No prior context, but a longer explanation**

```json
{
  "question": "Which rollout path should I implement first?",
  "description": "The code supports both paths, but only one can be validated before the demo window.",
  "options": [
    { "title": "Conservative path", "description": "Smallest change, easiest rollback.", "flags": ["favorite"] },
    { "title": "Full path", "description": "More complete, higher test surface." }
  ]
}
```

## Contributing

Contribution and public mirror policy is in [CONTRIBUTING.md](./CONTRIBUTING.md).

## Attribution

This package is based on Enzo Lucchesi's [`edlsh/pi-ask-user`](https://github.com/edlsh/pi-ask-user), an MIT-licensed Pi package for interactive prompts. The local Pi prompt implementation remains derived from that project, and the original copyright/license notice is preserved in [`LICENSE`](./LICENSE).
