@yunazgr/pi-companion 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/SECURITY.md +10 -2
- package/docs/automations.md +100 -0
- package/package.json +10 -3
- package/skills/companion-automations/SKILL.md +47 -0
- package/src/automation-mcp.ts +71 -0
- package/src/automation.ts +111 -0
- package/src/bridge.ts +5 -1
- package/src/index.ts +57 -1
- package/src/protocol.ts +1 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Lightweight, local-first remote control for Pi sessions.
|
|
4
4
|
|
|
5
|
+
**New in 0.3.1:** claymorphic automation workspaces with searchable, status-filtered, paginated lists; Summary/History tabs and calendar-style run cards; sanitized Markdown/ADF/JSON results; a staged job editor with strict validation and optional failed-action retries (up to 5, default 10-second interval). Per-automation history keeps the latest 30 runs by default, with 10 per page. Automated sessions are clearly marked as answers-only. Type `/` in a normal session to discover available commands, skills, and prompt templates. See [automations](docs/automations.md).
|
|
6
|
+
|
|
5
7
|
Pi Companion is deliberately not another agent runtime. Pi owns execution and conversation state. A single Rust daemon owns session discovery, pairing, temporary file exchange, browser fan-out, and the embedded web UI.
|
|
6
8
|
|
|
7
9
|
The UI is a static SvelteKit application. There is no Node runtime in production and no Tauri shell. Rust embeds the generated frontend into the daemon binary.
|
|
@@ -13,7 +15,7 @@ The UI is a static SvelteKit application. There is no Node runtime in production
|
|
|
13
15
|
<img src="docs/mobile.webp" alt="Session detail on a phone" width="28%" />
|
|
14
16
|
</p>
|
|
15
17
|
|
|
16
|
-
Screenshots use demo sessions, not private conversation data, and are regenerated with `node tools/screenshots.mjs` (see [Running locally from source](#running-locally-from-source)). See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp).
|
|
18
|
+
Screenshots use demo sessions, not private conversation data, and are regenerated with `node tools/screenshots.mjs` (see [Running locally from source](#running-locally-from-source)). See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp). Explore the new [automation list](docs/automations.webp), [summary](docs/automation-detail.webp), [run history](docs/automation-history.webp), [job editor](docs/automation-editor.webp), and [rich results](docs/automation-result.webp).
|
|
17
19
|
|
|
18
20
|
## Install
|
|
19
21
|
|
|
@@ -352,7 +354,7 @@ Clone the repository, then:
|
|
|
352
354
|
|
|
353
355
|
`npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
|
|
354
356
|
|
|
355
|
-
Pi Companion v0.
|
|
357
|
+
Pi Companion v0.3.1
|
|
356
358
|
|
|
357
359
|
Console http://127.0.0.1:43721
|
|
358
360
|
Paired devices http://127.0.0.1:43722
|
|
@@ -390,6 +392,8 @@ To regenerate the README screenshots from demo sessions (your running daemon is
|
|
|
390
392
|
npm run ui:build && cargo build --release --manifest-path server/Cargo.toml
|
|
391
393
|
node tools/screenshots.mjs
|
|
392
394
|
|
|
395
|
+
The generator also refreshes automation list/detail/history/editor/results, automated session, and [slash-command suggestions](docs/slash-commands.webp) screenshots. Type `/` in a shared session to search its available extension commands, skills, and prompt templates; use ↑/↓ and Tab/Enter to choose, then send when ready. Built-in terminal-only commands and ordinary tool names are not included. Automated answers-only sessions never expose this picker.
|
|
396
|
+
|
|
393
397
|
It needs Playwright and `cwebp`. Set `PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs` if Playwright isn't installed in this repo, and `CHROME=/path/to/chrome` to use an existing browser.
|
|
394
398
|
|
|
395
399
|
To test the same behavior as a published install, stop any development daemon first and install the package normally:
|
package/SECURITY.md
CHANGED
|
@@ -24,7 +24,7 @@ Strict Origin checks: WebSocket upgrades and POST/DELETE requests must carry an
|
|
|
24
24
|
|
|
25
25
|
Pairing invitations are single-use and expire (5 minutes by default). The short typed code is rate limited: ten wrong codes within ten minutes withdraw every open invitation and further code claims get HTTP 429 until the window passes.
|
|
26
26
|
|
|
27
|
-
Paired devices can see only sessions that explicitly enabled remote control.
|
|
27
|
+
Paired devices can see only sessions that explicitly enabled remote control, including daemon-owned automation sessions created by an authorized automation run. Paired devices may read automation definitions/history and start/stop existing automations, but cannot create, edit, delete, or enable/disable them.
|
|
28
28
|
|
|
29
29
|
Revoking a device deletes its credential hash and closes its open connections immediately (WebSocket close 4003). Disconnecting closes them without deleting the credential (close 4001).
|
|
30
30
|
|
|
@@ -32,6 +32,14 @@ Revoking a device deletes its credential hash and closes its open connections im
|
|
|
32
32
|
|
|
33
33
|
Paired devices (name, browser user agent, pairing and last-seen times, and the SHA-256 hash of the credential; never the credential) and settings are written atomically to `~/.pi/agent/pi-companion/state.json`. On Unix the directory is 0700 and the file is created 0600 before any byte is written; a looser mode found at startup is tightened.
|
|
34
34
|
|
|
35
|
+
Automation definitions, prompts, and bounded run output are stored separately in `automations.json`, using atomic replacement and private Unix permissions. Output can contain sensitive command/agent data; it is readable by paired devices. Failed or stopped runs are retained; interrupted runs are marked failed on daemon restart.
|
|
36
|
+
|
|
37
|
+
### Automations
|
|
38
|
+
|
|
39
|
+
Only the local admin surface can author automation scripts. Native extension and standalone MCP tools use that surface and do not start the daemon implicitly. Commands use argv without an implicit shell, but both commands and spawned Pi agents run with the OS user's permissions; this is not a sandbox. A paired device can start a previously authorized script with side effects. Disabling a definition prevents scheduling, not explicit manual runs.
|
|
40
|
+
|
|
41
|
+
Automation sessions accept only question answers/cancellation from browsers. Prompts, steering, abort/plan/diff commands, uploads, and file deletion are denied server-side. Stop a run through its automation endpoint instead. Five-field UTC schedules run only while the daemon is alive, without overlapping runs or catch-up. Automatic session archiving after seven disconnected days removes registry/feed records only, never local Pi/project files.
|
|
42
|
+
|
|
35
43
|
### Web UI hardening
|
|
36
44
|
|
|
37
45
|
HTML responses send a same-origin Content-Security-Policy (no third-party scripts, styles, fonts or connections), `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`. The UI loads nothing from the network beyond the daemon itself.
|
|
@@ -54,7 +62,7 @@ Agent deletion uses the opaque id rather than a path. Before unlinking, the daem
|
|
|
54
62
|
|
|
55
63
|
Pi Companion does not provide:
|
|
56
64
|
|
|
57
|
-
- remote shell access
|
|
65
|
+
- arbitrary remote shell access (paired devices can start admin-authored automation commands)
|
|
58
66
|
- arbitrary filesystem browsing
|
|
59
67
|
- arbitrary tool invocation
|
|
60
68
|
- direct git push/commit endpoints
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Automations (0.3.1)
|
|
2
|
+
|
|
3
|
+
Automations are persisted, named JSON scripts run by the Companion daemon. Open **Automations** in the navigation to view definitions, start/stop runs, and inspect timestamped run history. Click a run to view its captured output and Pi summary. Desktop console users can create, edit, delete, and enable/disable definitions; mobile and paired-device users can only inspect and start/stop them.
|
|
4
|
+
|
|
5
|
+
## Workspace & editor
|
|
6
|
+
|
|
7
|
+
The claymorphic automation collection supports name search, Enabled/Disabled filters, ten-item pagination, and its own scrollable card region. The detail workspace has **Summary** and **Run history** tabs; history is newest-first with calendar tiles, duration, status filters, and ten-item pagination over each automation’s retained history (latest 30 finished runs by default, configurable 1–1000).
|
|
8
|
+
|
|
9
|
+
The job editor separates name, enablement, UTC cron, optional retry policy, preconditions, actions, and post-actions. Each step is a reorderable JSON DSL object with Command/Pi templates. Saving validates the definition, cron ranges, action types, arguments, absolute directories, timeouts, step count, and retry bounds; the daemon validates again before persistence. Only computer desktop administrators can author definitions.
|
|
10
|
+
|
|
11
|
+
Run results have a formatted and raw view. Formatted results split daemon execution phases, render sanitized Markdown (including tables, links, code, and diagrams), recognise whole/fenced JSON, and render Atlassian Document Format (`type: "doc"`) or `{ "adf": ... }` documents. ADF supports headings, lists, marks, tables, panels, and safe links; unsupported nodes preserve their text, and attachments are placeholders. HTML/scripts and executable link protocols are not trusted. Raw output remains available for inspection and copying.
|
|
12
|
+
|
|
13
|
+
## Screenshots
|
|
14
|
+
|
|
15
|
+
Screenshots use only demo data. The same generator refreshes the existing overview, session, and mobile screenshots too.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+

|
|
21
|
+

|
|
22
|
+

|
|
23
|
+

|
|
24
|
+

|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## History retention
|
|
28
|
+
|
|
29
|
+
`historyLimit` is an optional integer **1–1000**, default **30**. Each automation retains its latest N finished runs, replacing older history as newer runs finish. Lowering the limit prunes older runs immediately on save; active runs are retained until finished. The same policy is applied on daemon startup. Deleted run results cannot be recovered from the dashboard. Run history pages show **10 items** at a time.
|
|
30
|
+
|
|
31
|
+
Automations also appear below running sessions on the overview. Automated sessions are labelled **Automated · answers only** in session cards, tables, and the session workspace. Their browser controls allow question answers but not steering, planning, uploads, or code changes; backend restrictions remain authoritative.
|
|
32
|
+
|
|
33
|
+
## Optional retries
|
|
34
|
+
|
|
35
|
+
`maxRetries` is an optional integer **0–5** (default **0**, no retries). `retryIntervalSeconds` is an optional integer **1–86400** (default **10 seconds**). Only a failed main action is retried; completed steps do not repeat, and precondition skips and finalizers are never retried. Stop cancels an active process or retry delay. Save-time validation requires the total retry-delay budget to be shorter than the shortest daily UTC trigger gap (a conservative bound for schedules limited to selected dates). Before each wait, the daemon also skips a retry that would reach the next scheduled trigger. It cannot guarantee action duration; overlapping runs remain prohibited. Attempt diagnostics are included in run output, and finalizers execute after the final outcome unless stopped.
|
|
36
|
+
|
|
37
|
+
**Retries can duplicate side effects**, including model calls and remote submissions after ambiguous failures. Enable them only for retry-safe work; the PR-review automation should keep retries off. Existing definitions without either field retain their original behavior.
|
|
38
|
+
|
|
39
|
+
## JSON DSL
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"name": "Daily repository check",
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"schedule": "0 9 * * 1-5",
|
|
46
|
+
"maxRetries": 0,
|
|
47
|
+
"retryIntervalSeconds": 10,
|
|
48
|
+
"historyLimit": 30,
|
|
49
|
+
"preconditions": [
|
|
50
|
+
{ "type": "command", "command": "git", "args": ["rev-parse", "--is-inside-work-tree"], "cwd": "/absolute/project" }
|
|
51
|
+
],
|
|
52
|
+
"actions": [
|
|
53
|
+
{ "type": "command", "command": "npm", "args": ["test"], "cwd": "/absolute/project", "timeoutSeconds": 600 },
|
|
54
|
+
{ "type": "pi", "prompt": "Summarize the repository's current health. Do not edit files.", "cwd": "/absolute/project", "timeoutSeconds": 900 }
|
|
55
|
+
],
|
|
56
|
+
"postActions": []
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Commands run as executable + argv, not through an implicit shell. A nonzero precondition skips the main actions. Actions execute in order. Post-actions provide a finalization stage. `schedule: null` is manual-only; otherwise use five cron fields (minute, hour, day of month, month, day of week), evaluated in **UTC**. Scheduled execution requires the daemon to remain running; it does not catch up missed executions. A definition cannot have overlapping runs. Disabling scheduling and stopping a run are separate operations.
|
|
61
|
+
|
|
62
|
+
A Pi action starts `pi --mode rpc --no-session` in the specified directory. Pi must be on PATH and already configured with credentials/model settings. It exposes a read-only Companion session: the feed and questions are visible, but steering, prompts, plan changes, file uploads, and diffs are unavailable. This restriction applies to browser control, **not** the agent's own tools or filesystem access. Pi's final assistant output is stored in the run result. Supported RPC `select`, `confirm`, `input`, and `editor` questions are forwarded; terminal-only custom dialogs cannot be displayed. The native `companion_ask_user` tool falls back to these RPC dialogs for daemon-owned runs.
|
|
63
|
+
|
|
64
|
+
**Security:** automations run with the daemon user's operating-system privileges. They are not sandboxed. Paired devices can start an existing automation, including commands with side effects. Pair only devices you trust, and review commands, prompts, working directories, and schedules before saving/enabling a definition. Native agent tools and MCP management use the local admin surface; do not expose that surface publicly.
|
|
65
|
+
|
|
66
|
+
## Agent surface
|
|
67
|
+
|
|
68
|
+
The extension registers `companion_automation` and ships the `companion-automations` skill. Operations: `list`, `get`, `create`, `update`, `delete`, `enable`, `disable`, `start`, `stop`, `runs`, and `run`. Supply `id` except for list/create, a full `automation` definition for create/update, and `runId` for run detail.
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{"action":"create","automation":{"name":"Check","enabled":true,"schedule":null,"preconditions":[],"actions":[{"type":"command","command":"git","args":["status","--short"],"cwd":"/absolute/project"}],"postActions":[]}}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Tools only contact an already-running daemon. They never download/start one or activate sharing for a normal Pi session. Start Companion explicitly via `/companion` or `npm run serve` first. Inspect the final run status rather than assuming an accepted start means success.
|
|
75
|
+
|
|
76
|
+
## Standalone MCP
|
|
77
|
+
|
|
78
|
+
Node >=22.17 can run the shipped stdio MCP server, without an MCP SDK dependency:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pi mcp add companion-automations -- node --experimental-strip-types --no-warnings /absolute/path/to/pi-companion/src/automation-mcp.ts
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Other MCP clients can configure the same executable and arguments. The server exposes `companion_automation` with the same schema as the native extension tool. Configure only one surface if you want to avoid duplicate tools. It uses the default local admin address, overridable with `PI_COMPANION_URL=ws://127.0.0.1:PORT`; non-loopback management targets are rejected.
|
|
85
|
+
|
|
86
|
+
## HTTP API
|
|
87
|
+
|
|
88
|
+
Admin console:
|
|
89
|
+
|
|
90
|
+
- `GET /api/automations` → `{automations}`; `POST` creates a definition.
|
|
91
|
+
- `GET /api/automations/{id}` → definition; `PUT` replaces it; `DELETE` removes it.
|
|
92
|
+
- `POST /api/automations/{id}/start` and `/stop`.
|
|
93
|
+
- `GET /api/automations/{id}/runs` → `{runs}`.
|
|
94
|
+
- `GET /api/automations/{id}/runs/{runId}` → run details including result.
|
|
95
|
+
|
|
96
|
+
The paired-device surface requires valid device authentication and allowed Origin, and exposes only GET/start/stop. Mobile layout is a UI restriction, not a separate authentication role; local console API callers remain administrators.
|
|
97
|
+
|
|
98
|
+
## Automatic archiving
|
|
99
|
+
|
|
100
|
+
Disconnected/stopped session records older than seven days are archived automatically. Live command channels, active/idle sessions, and recent disconnections are retained. Archiving removes only the daemon registry/feed entries, never the project, local Pi history, or files. Automation definitions and run history are independent of session archiving.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"main": "src/index.ts",
|
|
29
29
|
"files": [
|
|
30
30
|
"src",
|
|
31
|
+
"skills",
|
|
32
|
+
"docs/automations.md",
|
|
31
33
|
"README.md",
|
|
32
34
|
"SECURITY.md",
|
|
33
35
|
"LICENSE"
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
},
|
|
38
40
|
"scripts": {
|
|
39
41
|
"serve": "npm run --silent ui:build -- --logLevel warn && cargo run -q --manifest-path server/Cargo.toml",
|
|
42
|
+
"automation:mcp": "node --experimental-strip-types --no-warnings src/automation-mcp.ts",
|
|
40
43
|
"server:dev": "npm run serve",
|
|
41
44
|
"server:check": "npm run ui:build && cargo check --manifest-path server/Cargo.toml",
|
|
42
45
|
"ui:dev": "cd ui && vite dev",
|
|
@@ -44,13 +47,17 @@
|
|
|
44
47
|
"check": "tsc --noEmit && npm run ui:check",
|
|
45
48
|
"ui:check": "cd ui && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
|
46
49
|
"test": "npm run test:extension && npm run test:server",
|
|
47
|
-
"test:extension": "node --test --experimental-transform-types --no-warnings test/*.test.ts",
|
|
48
|
-
"test:server": "cargo test --manifest-path server/Cargo.toml"
|
|
50
|
+
"test:extension": "node --test --experimental-transform-types --no-warnings test/*.test.ts ui/automation.test.ts",
|
|
51
|
+
"test:server": "cargo test --manifest-path server/Cargo.toml",
|
|
52
|
+
"test:smoke": "npm run ui:build && cargo build --manifest-path server/Cargo.toml --locked && node tools/smoke-automations.mjs"
|
|
49
53
|
},
|
|
50
54
|
"pi": {
|
|
51
55
|
"extensions": [
|
|
52
56
|
"./src/index.ts"
|
|
53
57
|
],
|
|
58
|
+
"skills": [
|
|
59
|
+
"./skills"
|
|
60
|
+
],
|
|
54
61
|
"image": "https://raw.githubusercontent.com/ygrip/pi-companion/main/docs/dashboard.png"
|
|
55
62
|
},
|
|
56
63
|
"dependencies": {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: companion-automations
|
|
3
|
+
description: Create, update, delete, enable, disable, run, stop, and inspect Pi Companion automations with the companion_automation tool. Use for recurring repository tasks, scheduled agent work, and automation run history.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Companion automations
|
|
7
|
+
|
|
8
|
+
Use `companion_automation` (native extension or standalone MCP surface). It talks only to the local admin daemon; it does not launch the daemon or enable session sharing. If unavailable, ask the user to start Companion with `/companion` or `npm run serve`.
|
|
9
|
+
|
|
10
|
+
## Safety
|
|
11
|
+
|
|
12
|
+
Before saving or starting an automation, confirm the user's intended command, working directory, schedule, and side effects. Do not silently schedule destructive commands, spend model quota, or turn an untrusted prompt into a recurring task. Commands and Pi runs execute with the user's OS permissions, not in a sandbox. Remote/mobile clients can inspect, start, and stop existing definitions, but cannot author them.
|
|
13
|
+
|
|
14
|
+
Read the current definition with `get` before `update`, `enable`, or `disable`. Updating replaces the full definition. Preserve fields not intentionally changed. `disable` prevents scheduled runs; use `stop` separately to cancel a running automation. Definitions and run history are daemon-owned; archiving sessions never deletes local Pi data.
|
|
15
|
+
|
|
16
|
+
## Definition DSL
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"name": "Repository check",
|
|
21
|
+
"enabled": true,
|
|
22
|
+
"schedule": "0 9 * * 1-5",
|
|
23
|
+
"preconditions": [],
|
|
24
|
+
"actions": [
|
|
25
|
+
{ "type": "command", "command": "npm", "args": ["test"], "cwd": "/absolute/repo", "timeoutSeconds": 600 },
|
|
26
|
+
{ "type": "pi", "prompt": "Summarize repository health. Do not edit files.", "cwd": "/absolute/repo", "timeoutSeconds": 900 }
|
|
27
|
+
],
|
|
28
|
+
"postActions": []
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `schedule` is a five-field **UTC** cron expression; `null` means manual only. The daemon must be running for scheduling. There is no catch-up while it is stopped.
|
|
33
|
+
- Commands use executable plus argument array, **no implicit shell**. Shell syntax must never be passed as an executable. Avoid explicit shells unless the user requested and authorized them.
|
|
34
|
+
- Preconditions gate execution. Actions run sequentially. Post-actions are the cleanup/finalization stage; inspect the run result to determine failures.
|
|
35
|
+
- Optional `maxRetries` (integer 0–5, default 0) and `retryIntervalSeconds` (integer 1–86400, default 10) retry only the failed main action, never completed steps, preconditions, or finalizers. Stop cancels a retry delay. Retries may duplicate side effects or model usage: obtain authorization, and keep them off for non-idempotent remote submissions.
|
|
36
|
+
- Optional `historyLimit` (integer 1–1000, default 30) keeps the latest N finished runs per automation. Lowering it immediately removes older history on save. Retry delays must fit before the next scheduled trigger.
|
|
37
|
+
- A Pi action starts a daemon-owned, read-only Companion session: users can answer asks, but cannot steer, prompt, upload, request changes, or edit its plan. The agent itself still has ordinary Pi tool permissions.
|
|
38
|
+
- Pi must be installed on PATH and configured with model credentials. Its final assistant output is saved in run history.
|
|
39
|
+
|
|
40
|
+
## Tool operations
|
|
41
|
+
|
|
42
|
+
- `list`, `get` (`id`)
|
|
43
|
+
- `create` (`automation`), `update` (`id`, full `automation`)
|
|
44
|
+
- `delete`, `enable`, `disable`, `start`, `stop` (`id`)
|
|
45
|
+
- `runs` (`id`), `run` (`id`, `runId`)
|
|
46
|
+
|
|
47
|
+
After `start`, inspect `runs`/`run`; an accepted start is not completion. Report the actual final status and stored result. Do not retry a side-effecting run automatically after an ambiguous network error.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Standalone stdio MCP surface. Run with Node >=22.17 (native TypeScript stripping). */
|
|
3
|
+
import { automationTool, executeAutomation, type AutomationParams } from "./automation.ts";
|
|
4
|
+
|
|
5
|
+
const inFlight = new Map<string | number, AbortController>();
|
|
6
|
+
let output = Promise.resolve();
|
|
7
|
+
function send(record: unknown) {
|
|
8
|
+
const line = JSON.stringify(record) + "\n";
|
|
9
|
+
output = output.then(() => new Promise<void>((resolve, reject) => {
|
|
10
|
+
process.stdout.write(line, error => error ? reject(error) : resolve());
|
|
11
|
+
}));
|
|
12
|
+
void output.catch(() => { process.exitCode = 1; process.stdin.destroy(); });
|
|
13
|
+
}
|
|
14
|
+
const error = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
15
|
+
async function receive(line: string) {
|
|
16
|
+
let message: { jsonrpc?: string; id?: string | number; method?: string; params?: Record<string, unknown> };
|
|
17
|
+
try { message = JSON.parse(line); }
|
|
18
|
+
catch { error(null, -32700, "Invalid JSON"); return; }
|
|
19
|
+
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
20
|
+
error(message?.id ?? null, -32600, "Invalid JSON-RPC request"); return;
|
|
21
|
+
}
|
|
22
|
+
if (message.method === "notifications/cancelled") {
|
|
23
|
+
const id = message.params?.requestId;
|
|
24
|
+
if (typeof id === "string" || typeof id === "number") inFlight.get(id)?.abort();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (message.id === undefined) return;
|
|
28
|
+
const id = message.id;
|
|
29
|
+
const reply = (result: unknown) => send({ jsonrpc: "2.0", id, result });
|
|
30
|
+
switch (message.method) {
|
|
31
|
+
case "initialize": {
|
|
32
|
+
const versions = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
33
|
+
const requested = message.params?.protocolVersion;
|
|
34
|
+
reply({ protocolVersion: versions.includes(String(requested)) ? requested : versions[0], capabilities: { tools: {} }, serverInfo: { name: "pi-companion-automations", version: "0.3.1" } });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
case "ping": reply({}); return;
|
|
38
|
+
case "tools/list": reply({ tools: [automationTool] }); return;
|
|
39
|
+
case "tools/call": {
|
|
40
|
+
if (message.params?.name !== automationTool.name) { error(id, -32602, "Unknown tool"); return; }
|
|
41
|
+
const args = message.params?.arguments;
|
|
42
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) { error(id, -32602, "Expected tool arguments"); return; }
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
inFlight.set(id, controller);
|
|
45
|
+
try {
|
|
46
|
+
const result = await executeAutomation(args as AutomationParams, controller.signal);
|
|
47
|
+
reply({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
|
|
48
|
+
} catch (failure) {
|
|
49
|
+
reply({ isError: true, content: [{ type: "text", text: String(failure instanceof Error ? failure.message : failure) }] });
|
|
50
|
+
} finally { inFlight.delete(id); }
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
default: error(id, -32601, "Method not found");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Split only LF: Unicode line separators are legal inside JSON string values.
|
|
58
|
+
let buffer = Buffer.alloc(0);
|
|
59
|
+
for await (const chunk of process.stdin) {
|
|
60
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
61
|
+
let newline: number;
|
|
62
|
+
while ((newline = buffer.indexOf(10)) !== -1) {
|
|
63
|
+
const line = buffer.subarray(0, newline).toString("utf8").replace(/\r$/, "");
|
|
64
|
+
buffer = buffer.subarray(newline + 1);
|
|
65
|
+
if (Buffer.byteLength(line) > 1024 * 1024) { error(null, -32600, "Request too large"); continue; }
|
|
66
|
+
if (line.trim()) void receive(line);
|
|
67
|
+
}
|
|
68
|
+
if (buffer.length > 1024 * 1024) { error(null, -32600, "Request too large"); process.stdin.destroy(); break; }
|
|
69
|
+
}
|
|
70
|
+
for (const controller of inFlight.values()) controller.abort();
|
|
71
|
+
await output;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { adminHttpUrl } from "./daemon.ts";
|
|
2
|
+
|
|
3
|
+
export type AutomationAction =
|
|
4
|
+
| { type: "command"; command: string; args: string[]; cwd?: string; timeoutSeconds?: number }
|
|
5
|
+
| { type: "pi"; prompt: string; cwd: string; timeoutSeconds?: number };
|
|
6
|
+
export type AutomationDefinition = {
|
|
7
|
+
name: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
schedule: string | null;
|
|
10
|
+
preconditions: AutomationAction[];
|
|
11
|
+
actions: AutomationAction[];
|
|
12
|
+
postActions: AutomationAction[];
|
|
13
|
+
historyLimit?: number;
|
|
14
|
+
maxRetries?: number;
|
|
15
|
+
retryIntervalSeconds?: number;
|
|
16
|
+
};
|
|
17
|
+
export type AutomationParams = {
|
|
18
|
+
action: "list" | "get" | "create" | "update" | "delete" | "enable" | "disable" | "start" | "stop" | "runs" | "run";
|
|
19
|
+
id?: string;
|
|
20
|
+
runId?: string;
|
|
21
|
+
automation?: AutomationDefinition;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const actionSchema = {
|
|
25
|
+
oneOf: [
|
|
26
|
+
{ type: "object", additionalProperties: false, required: ["type", "command", "args"], properties: {
|
|
27
|
+
type: { const: "command" }, command: { type: "string", minLength: 1 },
|
|
28
|
+
args: { type: "array", items: { type: "string" } }, cwd: { type: "string" },
|
|
29
|
+
timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
|
|
30
|
+
} },
|
|
31
|
+
{ type: "object", additionalProperties: false, required: ["type", "prompt", "cwd"], properties: {
|
|
32
|
+
type: { const: "pi" }, prompt: { type: "string", minLength: 1 }, cwd: { type: "string", minLength: 1 },
|
|
33
|
+
timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
|
|
34
|
+
} }
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const automationTool = {
|
|
39
|
+
name: "companion_automation",
|
|
40
|
+
description: "Manage Pi Companion automations: list, get, create, update, delete, enable, disable, start, stop, runs, or run detail. Definitions use preconditions, actions, postActions and optional five-field UTC cron schedule. Commands execute argv without a shell. Mutations require the user's authorization; running actions has the computer user's permissions. Does not start the daemon or enable session sharing.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object", additionalProperties: false, required: ["action"],
|
|
43
|
+
properties: {
|
|
44
|
+
action: { type: "string", enum: ["list", "get", "create", "update", "delete", "enable", "disable", "start", "stop", "runs", "run"] },
|
|
45
|
+
id: { type: "string", minLength: 1 }, runId: { type: "string", minLength: 1 },
|
|
46
|
+
automation: {
|
|
47
|
+
type: "object", additionalProperties: false,
|
|
48
|
+
required: ["name", "enabled", "schedule", "preconditions", "actions", "postActions"],
|
|
49
|
+
properties: {
|
|
50
|
+
name: { type: "string", minLength: 1 }, enabled: { type: "boolean" },
|
|
51
|
+
schedule: { type: ["string", "null"], description: "Five-field UTC cron; null for manual only." },
|
|
52
|
+
preconditions: { type: "array", items: actionSchema },
|
|
53
|
+
actions: { type: "array", minItems: 1, items: actionSchema },
|
|
54
|
+
postActions: { type: "array", items: actionSchema },
|
|
55
|
+
historyLimit: { type: "integer", minimum: 1, maximum: 1000, description: "Retain latest N finished runs; default 30." },
|
|
56
|
+
maxRetries: { type: "integer", minimum: 0, maximum: 5, description: "Optional retries of the failed main action only; default 0. May repeat side effects." },
|
|
57
|
+
retryIntervalSeconds: { type: "integer", minimum: 1, maximum: 86400, description: "Optional delay between retries; default 10 seconds; total retry delay must fit before the next scheduled trigger." }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Explicit tool calls only: never probe, download, or launch a daemon here. */
|
|
65
|
+
export async function executeAutomation(params: AutomationParams, signal?: AbortSignal) {
|
|
66
|
+
const actions = ['list', 'get', 'create', 'update', 'delete', 'enable', 'disable', 'start', 'stop', 'runs', 'run'];
|
|
67
|
+
if (!params || typeof params !== 'object' || !actions.includes(params.action)) throw new Error("Unknown automation action.");
|
|
68
|
+
if (params.id !== undefined && (typeof params.id !== 'string' || !params.id.trim())) throw new Error("id must be a nonempty string.");
|
|
69
|
+
if (params.runId !== undefined && (typeof params.runId !== 'string' || !params.runId.trim())) throw new Error("runId must be a nonempty string.");
|
|
70
|
+
const base = adminHttpUrl();
|
|
71
|
+
const target = new URL(base);
|
|
72
|
+
if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(target.hostname) || !['http:', 'https:'].includes(target.protocol)) {
|
|
73
|
+
throw new Error("Automation management requires the local Companion admin address.");
|
|
74
|
+
}
|
|
75
|
+
const request = async (path: string, method = "GET", body?: unknown) => {
|
|
76
|
+
const response = await fetch(base + path, {
|
|
77
|
+
method,
|
|
78
|
+
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
|
79
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
80
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000)
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) throw new Error(`Companion ${response.status}: ${await response.text()}`);
|
|
83
|
+
return response.status === 204 ? { ok: true } : await response.json();
|
|
84
|
+
};
|
|
85
|
+
if (params.action === "list") return request("/api/automations");
|
|
86
|
+
if (params.action === "create") {
|
|
87
|
+
if (!params.automation) throw new Error("create requires automation.");
|
|
88
|
+
return request("/api/automations", "POST", params.automation);
|
|
89
|
+
}
|
|
90
|
+
if (!params.id) throw new Error(`${params.action} requires id.`);
|
|
91
|
+
const path = "/api/automations/" + encodeURIComponent(params.id);
|
|
92
|
+
switch (params.action) {
|
|
93
|
+
case "get": return request(path);
|
|
94
|
+
case "update":
|
|
95
|
+
if (!params.automation) throw new Error("update requires a complete automation definition.");
|
|
96
|
+
return request(path, "PUT", params.automation);
|
|
97
|
+
case "delete": return request(path, "DELETE");
|
|
98
|
+
case "enable": case "disable": {
|
|
99
|
+
const current = await request(path) as AutomationDefinition;
|
|
100
|
+
// PUT only definition fields, never server-generated metadata.
|
|
101
|
+
const { name, schedule, preconditions, actions, postActions, maxRetries, retryIntervalSeconds, historyLimit } = current;
|
|
102
|
+
return request(path, "PUT", { name, schedule, preconditions, actions, postActions, maxRetries, retryIntervalSeconds, historyLimit, enabled: params.action === "enable" });
|
|
103
|
+
}
|
|
104
|
+
case "start": case "stop": return request(path + "/" + params.action, "POST");
|
|
105
|
+
case "runs": return request(path + "/runs");
|
|
106
|
+
case "run":
|
|
107
|
+
if (!params.runId) throw new Error("run requires runId.");
|
|
108
|
+
return request(path + "/runs/" + encodeURIComponent(params.runId));
|
|
109
|
+
default: throw new Error("Unknown automation action.");
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/bridge.ts
CHANGED
|
@@ -70,7 +70,11 @@ export class CompanionBridge implements AskChannel {
|
|
|
70
70
|
shortTitle: name?.trim() || cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
|
|
71
71
|
mainModel: ctx.model?.id ?? null,
|
|
72
72
|
effort: ctx.thinkingLevel ?? this.pi.getThinkingLevel?.() ?? null,
|
|
73
|
-
telemetry: this.telemetry.snapshot(ctx)
|
|
73
|
+
telemetry: this.telemetry.snapshot(ctx),
|
|
74
|
+
// Expose only executable slash commands, not local source paths or credentials.
|
|
75
|
+
commands: (this.pi.getCommands?.() ?? []).slice(0, 500).map(command => ({
|
|
76
|
+
name: command.name, description: command.description?.slice(0, 500), source: command.source
|
|
77
|
+
}))
|
|
74
78
|
};
|
|
75
79
|
const patch: Partial<SessionSnapshot> = {};
|
|
76
80
|
for (const key of Object.keys(next) as Array<keyof typeof next>) {
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { formatAnswers, toQuestions } from "./ask.js";
|
|
4
4
|
import { CompanionBridge } from "./bridge.js";
|
|
5
5
|
import { adminHttpUrl, ensureDaemon } from "./daemon.js";
|
|
6
|
+
import { automationTool, executeAutomation, type AutomationParams } from "./automation.ts";
|
|
6
7
|
|
|
7
8
|
const AskOptionParam = Type.Union([
|
|
8
9
|
Type.String(),
|
|
@@ -143,6 +144,22 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
143
144
|
bridge.close();
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
pi.registerTool({
|
|
148
|
+
name: automationTool.name,
|
|
149
|
+
label: "Companion automation",
|
|
150
|
+
description: automationTool.description,
|
|
151
|
+
parameters: Type.Unsafe<AutomationParams>(automationTool.inputSchema),
|
|
152
|
+
executionMode: "sequential",
|
|
153
|
+
async execute(_toolCallId, params, signal) {
|
|
154
|
+
try {
|
|
155
|
+
const result = await executeAutomation(params, signal);
|
|
156
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { result, error: null as string | null } };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { isError: true, content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }], details: { result: null, error: String(error) } };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
146
163
|
pi.registerTool({
|
|
147
164
|
name: "companion_ask_user",
|
|
148
165
|
label: "Ask via Companion",
|
|
@@ -150,7 +167,46 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
150
167
|
"Ask the user one to four questions through the Pi Companion web/phone UI. Each question can offer options (single or multi-select) and an optional free-text answer.",
|
|
151
168
|
parameters: AskParams,
|
|
152
169
|
executionMode: "sequential",
|
|
153
|
-
async execute(_toolCallId, params, signal) {
|
|
170
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
171
|
+
// Daemon-owned RPC runs relay supported extension UI dialogs, not a second bridge.
|
|
172
|
+
// Normal sessions still require explicit /companion activation.
|
|
173
|
+
if (ctx?.mode === "rpc" && process.env.PI_COMPANION_AUTOMATION_RUN_ID) {
|
|
174
|
+
const items = params.questions?.length ? params.questions : params.question ? [{ question: params.question, options: params.options }] : [];
|
|
175
|
+
if (!items.length) return { content: [{ type: "text", text: "Provide `question` or `questions`." }], details: { questions: [], answers: null } };
|
|
176
|
+
const questions = toQuestions(items);
|
|
177
|
+
const answers: Record<string, string[]> = {};
|
|
178
|
+
for (const question of questions) {
|
|
179
|
+
if (signal?.aborted) break;
|
|
180
|
+
const choices = question.options.map(option => option.label);
|
|
181
|
+
const title = question.question + (question.options.some(option => option.description) ? "\n" + question.options.map(option => option.label + (option.description ? ": " + option.description : "")).join("\n") : "");
|
|
182
|
+
if (choices.length && !question.multiSelect) {
|
|
183
|
+
const custom = "Other (enter a response)";
|
|
184
|
+
const selected = await ctx.ui.select(title, question.allowCustom ? [...choices, custom] : choices, { signal, timeout: ASK_TIMEOUT_MS });
|
|
185
|
+
if (selected === undefined) break;
|
|
186
|
+
if (question.allowCustom && selected === custom) {
|
|
187
|
+
const value = await ctx.ui.input(question.question, "Your response", { signal, timeout: ASK_TIMEOUT_MS });
|
|
188
|
+
if (value === undefined) break;
|
|
189
|
+
answers[question.id] = [value];
|
|
190
|
+
} else answers[question.id] = [selected];
|
|
191
|
+
} else if (question.multiSelect && choices.length) {
|
|
192
|
+
answers[question.id] = [];
|
|
193
|
+
for (const choice of choices) {
|
|
194
|
+
if (signal?.aborted) break;
|
|
195
|
+
if (await ctx.ui.confirm(question.question, "Select: " + choice, { signal, timeout: ASK_TIMEOUT_MS })) answers[question.id].push(choice);
|
|
196
|
+
}
|
|
197
|
+
if (question.allowCustom) {
|
|
198
|
+
const value = await ctx.ui.input(question.question, "Optional additional response", { signal, timeout: ASK_TIMEOUT_MS });
|
|
199
|
+
if (value?.trim()) answers[question.id].push(value);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const value = await ctx.ui.input(title, question.placeholder, { signal, timeout: ASK_TIMEOUT_MS });
|
|
203
|
+
if (value === undefined) break;
|
|
204
|
+
answers[question.id] = [value];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const complete = !signal?.aborted && questions.every(question => question.id in answers);
|
|
208
|
+
return { content: [{ type: "text", text: complete ? formatAnswers(questions, answers) : "The user dismissed the question in Pi Companion." }], details: { questions, answers: complete ? answers : null } };
|
|
209
|
+
}
|
|
154
210
|
if (!bridge.isActivated()) {
|
|
155
211
|
return {
|
|
156
212
|
content: [{ type: "text", text: "Pi Companion is not enabled for this session. Run /companion before asking through the dashboard." }],
|
package/src/protocol.ts
CHANGED
|
@@ -61,6 +61,7 @@ export type SessionSnapshot = {
|
|
|
61
61
|
effort?: string | null;
|
|
62
62
|
telemetry?: SessionTelemetry;
|
|
63
63
|
remoteEnabled: boolean;
|
|
64
|
+
commands?: { name: string; description?: string; source: 'extension' | 'prompt' | 'skill' }[];
|
|
64
65
|
connectedAt: string;
|
|
65
66
|
/** Questions waiting for an answer. Kept in the snapshot so late-joining browsers see them. */
|
|
66
67
|
asks: AskRequest[];
|