@yunazgr/pi-companion 0.2.6 → 0.3.0

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Lightweight, local-first remote control for Pi sessions.
4
4
 
5
+ **New in 0.3.0:** [automations](docs/automations.md) with manual/UTC-cron runs, read-only agent sessions, run history, native agent tools and a standalone MCP surface. Disconnected sessions auto-archive after seven days; mobile Settings is now in the header.
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.
@@ -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.2.6
357
+ Pi Companion v0.3.0
356
358
 
357
359
  Console http://127.0.0.1:43721
358
360
  Paired devices http://127.0.0.1:43722
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,63 @@
1
+ # Automations (0.3.0)
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
+ ## JSON DSL
6
+
7
+ ```json
8
+ {
9
+ "name": "Daily repository check",
10
+ "enabled": true,
11
+ "schedule": "0 9 * * 1-5",
12
+ "preconditions": [
13
+ { "type": "command", "command": "git", "args": ["rev-parse", "--is-inside-work-tree"], "cwd": "/absolute/project" }
14
+ ],
15
+ "actions": [
16
+ { "type": "command", "command": "npm", "args": ["test"], "cwd": "/absolute/project", "timeoutSeconds": 600 },
17
+ { "type": "pi", "prompt": "Summarize the repository's current health. Do not edit files.", "cwd": "/absolute/project", "timeoutSeconds": 900 }
18
+ ],
19
+ "postActions": []
20
+ }
21
+ ```
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ **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.
28
+
29
+ ## Agent surface
30
+
31
+ 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.
32
+
33
+ ```json
34
+ {"action":"create","automation":{"name":"Check","enabled":true,"schedule":null,"preconditions":[],"actions":[{"type":"command","command":"git","args":["status","--short"],"cwd":"/absolute/project"}],"postActions":[]}}
35
+ ```
36
+
37
+ 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.
38
+
39
+ ## Standalone MCP
40
+
41
+ Node >=22.17 can run the shipped stdio MCP server, without an MCP SDK dependency:
42
+
43
+ ```bash
44
+ pi mcp add companion-automations -- node --experimental-strip-types --no-warnings /absolute/path/to/pi-companion/src/automation-mcp.ts
45
+ ```
46
+
47
+ 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.
48
+
49
+ ## HTTP API
50
+
51
+ Admin console:
52
+
53
+ - `GET /api/automations` → `{automations}`; `POST` creates a definition.
54
+ - `GET /api/automations/{id}` → definition; `PUT` replaces it; `DELETE` removes it.
55
+ - `POST /api/automations/{id}/start` and `/stop`.
56
+ - `GET /api/automations/{id}/runs` → `{runs}`.
57
+ - `GET /api/automations/{id}/runs/{runId}` → run details including result.
58
+
59
+ 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.
60
+
61
+ ## Automatic archiving
62
+
63
+ 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.2.6",
3
+ "version": "0.3.0",
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,45 @@
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
+ - 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.
36
+ - Pi must be installed on PATH and configured with model credentials. Its final assistant output is saved in run history.
37
+
38
+ ## Tool operations
39
+
40
+ - `list`, `get` (`id`)
41
+ - `create` (`automation`), `update` (`id`, full `automation`)
42
+ - `delete`, `enable`, `disable`, `start`, `stop` (`id`)
43
+ - `runs` (`id`), `run` (`id`, `runId`)
44
+
45
+ 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.0" } });
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,105 @@
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
+ };
14
+ export type AutomationParams = {
15
+ action: "list" | "get" | "create" | "update" | "delete" | "enable" | "disable" | "start" | "stop" | "runs" | "run";
16
+ id?: string;
17
+ runId?: string;
18
+ automation?: AutomationDefinition;
19
+ };
20
+
21
+ const actionSchema = {
22
+ oneOf: [
23
+ { type: "object", additionalProperties: false, required: ["type", "command", "args"], properties: {
24
+ type: { const: "command" }, command: { type: "string", minLength: 1 },
25
+ args: { type: "array", items: { type: "string" } }, cwd: { type: "string" },
26
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
27
+ } },
28
+ { type: "object", additionalProperties: false, required: ["type", "prompt", "cwd"], properties: {
29
+ type: { const: "pi" }, prompt: { type: "string", minLength: 1 }, cwd: { type: "string", minLength: 1 },
30
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
31
+ } }
32
+ ]
33
+ };
34
+
35
+ export const automationTool = {
36
+ name: "companion_automation",
37
+ 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.",
38
+ inputSchema: {
39
+ type: "object", additionalProperties: false, required: ["action"],
40
+ properties: {
41
+ action: { type: "string", enum: ["list", "get", "create", "update", "delete", "enable", "disable", "start", "stop", "runs", "run"] },
42
+ id: { type: "string", minLength: 1 }, runId: { type: "string", minLength: 1 },
43
+ automation: {
44
+ type: "object", additionalProperties: false,
45
+ required: ["name", "enabled", "schedule", "preconditions", "actions", "postActions"],
46
+ properties: {
47
+ name: { type: "string", minLength: 1 }, enabled: { type: "boolean" },
48
+ schedule: { type: ["string", "null"], description: "Five-field UTC cron; null for manual only." },
49
+ preconditions: { type: "array", items: actionSchema },
50
+ actions: { type: "array", minItems: 1, items: actionSchema },
51
+ postActions: { type: "array", items: actionSchema }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ };
57
+
58
+ /** Explicit tool calls only: never probe, download, or launch a daemon here. */
59
+ export async function executeAutomation(params: AutomationParams, signal?: AbortSignal) {
60
+ const actions = ['list', 'get', 'create', 'update', 'delete', 'enable', 'disable', 'start', 'stop', 'runs', 'run'];
61
+ if (!params || typeof params !== 'object' || !actions.includes(params.action)) throw new Error("Unknown automation action.");
62
+ if (params.id !== undefined && (typeof params.id !== 'string' || !params.id.trim())) throw new Error("id must be a nonempty string.");
63
+ if (params.runId !== undefined && (typeof params.runId !== 'string' || !params.runId.trim())) throw new Error("runId must be a nonempty string.");
64
+ const base = adminHttpUrl();
65
+ const target = new URL(base);
66
+ if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(target.hostname) || !['http:', 'https:'].includes(target.protocol)) {
67
+ throw new Error("Automation management requires the local Companion admin address.");
68
+ }
69
+ const request = async (path: string, method = "GET", body?: unknown) => {
70
+ const response = await fetch(base + path, {
71
+ method,
72
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
73
+ body: body === undefined ? undefined : JSON.stringify(body),
74
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000)
75
+ });
76
+ if (!response.ok) throw new Error(`Companion ${response.status}: ${await response.text()}`);
77
+ return response.status === 204 ? { ok: true } : await response.json();
78
+ };
79
+ if (params.action === "list") return request("/api/automations");
80
+ if (params.action === "create") {
81
+ if (!params.automation) throw new Error("create requires automation.");
82
+ return request("/api/automations", "POST", params.automation);
83
+ }
84
+ if (!params.id) throw new Error(`${params.action} requires id.`);
85
+ const path = "/api/automations/" + encodeURIComponent(params.id);
86
+ switch (params.action) {
87
+ case "get": return request(path);
88
+ case "update":
89
+ if (!params.automation) throw new Error("update requires a complete automation definition.");
90
+ return request(path, "PUT", params.automation);
91
+ case "delete": return request(path, "DELETE");
92
+ case "enable": case "disable": {
93
+ const current = await request(path) as AutomationDefinition;
94
+ // PUT only definition fields, never server-generated metadata.
95
+ const { name, schedule, preconditions, actions, postActions } = current;
96
+ return request(path, "PUT", { name, schedule, preconditions, actions, postActions, enabled: params.action === "enable" });
97
+ }
98
+ case "start": case "stop": return request(path + "/" + params.action, "POST");
99
+ case "runs": return request(path + "/runs");
100
+ case "run":
101
+ if (!params.runId) throw new Error("run requires runId.");
102
+ return request(path + "/runs/" + encodeURIComponent(params.runId));
103
+ default: throw new Error("Unknown automation action.");
104
+ }
105
+ }
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." }],