diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d76f53..c03b301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.17.0 + +Adds approvals. Your agents can now ask for your approval before they act, such as sending an email or deploying. `activitysmith approval request` shows the request on your iPhone and waits for your answer. It exits with code 0 only when you approve, so the agent continues only after that. `approval wait`, `approval get`, and `approval cancel` work with a request you already sent. + ## 1.16.0 Adds two commands so a coding agent can finish setup on its own. `activitysmith devices list` shows the iPhones and iPads paired to your account, and points to the App Store when none is paired. `activitysmith keys create` creates an API key for CI, servers, and scripts with the browser sign-in; the first time, it asks you to approve API key access in the browser. Add `--raw` to print only the key, so it can go straight into a secret store. diff --git a/README.md b/README.md index 4d8db1d..38444a3 100644 --- a/README.md +++ b/README.md @@ -692,6 +692,73 @@ Pass `0` to clear the badge. activitysmith badge 0 ``` +## Approvals + +![Approval Live Activity on an iPhone Lock Screen with Send and Deny buttons](https://cdn.activitysmith.com/features/ai-agent-approval-live-activity-phone.png) + +Let your agents ask for your approval before they act, such as sending an email to customers or deploying to production. The request reaches your iPhone as a Live Activity or an actionable Push Notification, both with two buttons. The agent waits until you tap one and continues only when you approve. + +```bash +activitysmith approval request "Send the pricing update email to 214 customers?" \ + --approve-label "Send" \ + --reject-label "Don't Send" +``` + +When you approve, the command prints `Approved.` and exits with code `0`. The agent, or a script, runs the action only after that: + +```bash +activitysmith approval request "Deploy to production?" --approve-label "Deploy" && ./deploy.sh +``` + +Exit codes for `approval request` and `approval wait`: + +- `0`: Approved. Continue with the action. +- `1`: Rejected, timed out after 30 minutes, or cancelled. Do not continue. +- `2`: The request failed, for example because no device is paired. +- `3`: Still pending after `--timeout`, or sent with `--no-wait`. + +### Show the exact command + +Add `--command` with the exact command that waits for approval. The request shows the command and the current directory: + +```bash +activitysmith approval request "Run the database migration?" \ + --approve-label "Migrate" \ + --command "npm run db:migrate -- --env production" +``` + +Add `--details` for short context. Add `--scope` to show where the action applies, such as `environment:production`. + +### Wait in steps + +Some agents stop shell commands after a few minutes. Add `--timeout` to stop waiting early. The command then prints the approval ID and exits with code `3`. Run `approval wait` to keep waiting: + +```bash +activitysmith approval request "Publish the release notes?" --timeout 100 +activitysmith approval wait apr_Xk3pQ8mV2nR7tL9w --timeout 100 +``` + +### Check or cancel a request + +```bash +activitysmith approval get apr_Xk3pQ8mV2nR7tL9w +activitysmith approval cancel apr_Xk3pQ8mV2nR7tL9w +``` + +Cancel a request when the action is no longer needed. Its buttons stop working, and a Live Activity updates to show the request was cancelled. + +### Change how the request looks + +By default, the request is a Live Activity, with an actionable Push Notification as the fallback. Add `--delivery push_notification` to always send a Push Notification, or `--delivery live_activity` to always send a Live Activity. Use `--badge`, `--color`, and `--icon` to change the Live Activity: + +```bash +activitysmith approval request "Refund order #4821 for $129?" \ + --approve-label "Refund" \ + --badge "Billing" \ + --color orange \ + --icon creditcard +``` + ## Metadata Metadata adds extra information to Push Notification and Live Activity details in ActivitySmith. It does not appear in the notification or Live Activity on your device. diff --git a/package.json b/package.json index 7c28262..2c115b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activitysmith-cli", - "version": "1.16.0", + "version": "1.17.0", "description": "Command-line interface for ActivitySmith. Send Push Notifications, start, update, and end Live Activities, and set App Icon Badge Counts from your terminal.", "keywords": [ "activitysmith", diff --git a/skills/activitysmith/SKILL.md b/skills/activitysmith/SKILL.md index 6fd1413..820b24a 100644 --- a/skills/activitysmith/SKILL.md +++ b/skills/activitysmith/SKILL.md @@ -1,6 +1,6 @@ --- name: activitysmith -description: Send ActivitySmith Push Notifications, start, update, and end Live Activities, set App Icon Badge Counts, and include notification taps or actions that run a specific iPhone Shortcut from coding agents through the ActivitySmith CLI. +description: Send ActivitySmith Push Notifications, start, update, and end Live Activities, ask for approval on the user's iPhone before an action, set App Icon Badge Counts, and include notification taps or actions that run a specific iPhone Shortcut from coding agents through the ActivitySmith CLI. --- # ActivitySmith @@ -14,6 +14,8 @@ Use this skill when the user wants an iPhone/iPad signal from an agent: - "keep a metric, status, or progress visible while you work" - "show this metric on my Lock Screen" - "tell me if you get blocked" +- "ask me before you send emails to customers" +- "get my approval before any purchase or deploy" The CLI is the tool. This skill is the intent mapping and recipe layer. @@ -49,6 +51,7 @@ If it prints `No paired devices.`, give the user the App Store link it prints. A - Push Notification with actions: long-press should show up to 4 buttons. - Live Activity: a real-time metric, operational state, or ongoing task should stay visible. - Widget metric: one stable value should stay visible after the task ends. +- Approval: the user asked to approve a kind of action before you do it. - Channels: only specific teammates/devices should receive the update. - Tags: related Push Notifications and Live Activities should be grouped in history. @@ -188,6 +191,24 @@ Use this when the agent needs human input. -m "I hit a blocker and need your decision." ``` +### Ask For Approval Before An Action + +Use this when the user set a rule, such as "ask me before you send emails to customers" or "get my approval before any purchase". The rule applies every time. Ask right before each matching action: + +```bash +activitysmith approval request "Send the pricing update email to 214 customers?" \ + --approve-label "Send" \ + --reject-label "Don't Send" \ + --timeout 100 +``` + +- Exit `0` (`Approved.`): do the action. +- Exit `1` (rejected, timed out, or cancelled): do not do the action. Tell the user. +- Exit `3` (`Still pending`): run `activitysmith approval wait --timeout 100`. Repeat until it exits `0` or `1`. +- Exit `2`: the request failed. Report the error and do not do the action. + +Write a question the user can answer from the Lock Screen. Use a concrete approve label, such as Send, Deploy, or Buy. For a shell command, pass the exact command with `--command`. If the action is no longer needed, run `activitysmith approval cancel `. + ### Update A Widget Metric Use this when a stable metric should stay visible on the Lock Screen. @@ -247,5 +268,6 @@ activitysmith badge 3 --channels "sales,customer-success" - For long-running progress, start one Live Activity, update it, then end it. - Add tags when the user wants related Push Notifications and Live Activities grouped in history. - Capture the Activity ID when using start/update/end. +- Never do an action the user asked to approve without an `Approved.` result. One approval covers one action. - Do not expose API keys, tokens, or secrets in titles, messages, action URLs, or webhook bodies. - If a command fails, report the error and do not retry repeatedly without a change. diff --git a/src/cli.js b/src/cli.js index 6a7cf80..7cae7d0 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1221,7 +1221,7 @@ const buildErrorDetails = async (error) => { // Set when a command authenticates with the browser sign-in rather than an API key. let usingSignIn = false; -const handleError = async (error, options) => { +const handleError = async (error, options, exitCode = 1) => { const details = await buildErrorDetails(error); // Disconnecting the CLI in the web app rejects its token at once, before it expires. if (usingSignIn && details.status === 401) { @@ -1256,7 +1256,7 @@ const handleError = async (error, options) => { } } - process.exit(1); + process.exit(exitCode); }; // Returns the API key, or a current access token from the saved sign-in. The REST API accepts @@ -1439,9 +1439,7 @@ metricsCommand .description("Update a widget metric value") .argument("", "Metric key") .argument("", "Metric value") - .option("--metadata ", "Metadata JSON object shown in ActivitySmith details") - .option("--metadata-file ", "Path to a Metadata JSON object file") - .action(async (metricKey, rawValue) => { + .action(async (metricKey, rawValue) => { const globalOptions = program.opts(); try { @@ -1837,6 +1835,223 @@ keysCommand } }); +// Approvals. Agents ask the user for approval before an action the user wants to approve, such as +// sending an email or deploying, and go ahead only when this exits 0. +const APPROVAL_SCOPE_TYPES = ["working_directory", "repository", "project", "environment", "customer", "account", "resource"]; +const APPROVAL_COLORS = ["lime", "green", "cyan", "blue", "purple", "magenta", "red", "orange", "yellow", "gray"]; +// Each request waits at most this long on the server, then the CLI asks again. +const APPROVAL_POLL_SECONDS = 45; +const APPROVAL_EXIT = { approved: 0, not_approved: 1, error: 2, pending: 3 }; + +const parseApprovalScope = (value) => { + const separator = value.indexOf(":"); + const type = separator > 0 ? value.slice(0, separator).trim() : ""; + const scopeValue = separator > 0 ? value.slice(separator + 1).trim() : ""; + if (!APPROVAL_SCOPE_TYPES.includes(type) || !scopeValue) { + throw new InvalidArgumentError( + `scope must be :, where type is one of ${APPROVAL_SCOPE_TYPES.join(", ")}` + ); + } + return { type, value: scopeValue }; +}; + +const parseApprovalColor = (value) => { + if (!APPROVAL_COLORS.includes(value)) { + throw new InvalidArgumentError(`color must be one of ${APPROVAL_COLORS.join(", ")}`); + } + return value; +}; + +const parsePositiveSeconds = (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new InvalidArgumentError("timeout must be a whole number of seconds, 1 or more"); + } + return parsed; +}; + +const APPROVAL_RESULT_LINES = { + approved: "Approved.", + rejected: "Rejected. Do not continue with the action.", + timed_out: "Timed out. No decision within 30 minutes. Do not continue with the action.", + cancelled: "Cancelled. Do not continue with the action.", +}; + +// Waits until the approval is decided, or until `timeoutSeconds` pass, then prints the result and exits. +const waitForApproval = async (approvalId, token, timeoutSeconds, globalOptions) => { + const deadline = timeoutSeconds ? Date.now() + timeoutSeconds * 1000 : Number.POSITIVE_INFINITY; + let result; + while (true) { + const remaining = Math.ceil((deadline - Date.now()) / 1000); + const wait = Math.max(1, Math.min(APPROVAL_POLL_SECONDS, remaining)); + const startedAt = Date.now(); + result = await apiRequest(`/approvals/${encodeURIComponent(approvalId)}?wait=${wait}`, { token }); + if (result.approval?.status !== "pending" || Date.now() >= deadline) break; + // The server normally holds the request until it decides or the wait ends; never ask again in a tight loop. + await new Promise((resolve) => setTimeout(resolve, Math.max(0, 1000 - (Date.now() - startedAt)))); + } + + const status = result.approval?.status; + if (status === "pending") { + outputResult(result, globalOptions, [ + `Still pending. Approval ID: ${approvalId}`, + `Run activitysmith approval wait ${approvalId} to keep waiting.`, + ]); + process.exit(APPROVAL_EXIT.pending); + } + outputResult(result, globalOptions, [APPROVAL_RESULT_LINES[status] ?? `Status: ${status}`]); + process.exit(status === "approved" ? APPROVAL_EXIT.approved : APPROVAL_EXIT.not_approved); +}; + +const approvalCommand = program + .command("approval") + .description("Let your agents ask for your approval on your iPhone before they act") + .addHelpText( + "after", + ` +Exit codes for request and wait: + 0 Approved. Continue with the action. + 1 Rejected, timed out, or cancelled. Do not continue. + 2 The request failed, for example no paired device. + 3 Still pending after --timeout or with --no-wait. Run approval wait with the approval ID.` + ); + +approvalCommand + .command("request") + .description("Send an approval request with two buttons to the iPhone and wait for the decision") + .argument("", 'The decision to make, such as "Send the pricing update email to 214 customers?"') + .option("--details ", "Short context that helps decide") + .option("--approve-label