Unattended agent work?
++ Schedule fresh Pi sessions with fixed models and hard timeouts, then inspect run duration, tokens, and + estimated cost from the workspace Automations view. +
+ Explore automations → +diff --git a/.changeset/persistent-scheduled-sessions.md b/.changeset/persistent-scheduled-sessions.md new file mode 100644 index 00000000..101a7180 --- /dev/null +++ b/.changeset/persistent-scheduled-sessions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add first-class workspace automations on local and federated machines with manual, one-shot, interval, and timezone-aware cron triggers. Each target machine stores and executes its own schedules in fresh daemon-owned Pi sessions with fixed model and thinking settings, configurable timeouts, durable cancellation, immutable run history, and visual duration, token, and estimated-cost reporting. diff --git a/README.md b/README.md index 314c248b..b5272e0f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ PI WEB lets you: - keep Pi Coding Agent sessions alive after browser disconnects; - run agents inside real repositories and git worktrees; - supervise multiple sessions in parallel; +- schedule workspace automations with model, timeout, cancellation, token, and estimated-cost visibility; - switch between laptop, phone, tablet, and desktop; - use a server, workstation, or remote dev box as your agent runtime; - manage projects, workspaces, files, terminals, sessions, and remote machines from one web UI. @@ -176,6 +177,7 @@ It is not a sandbox, permission system, or multi-tenant platform. Do not expose - [Install](https://pi-web.dev/install) - [Remote-first development](https://pi-web.dev/remote-first) - [Machines / fleet](https://pi-web.dev/machines) +- [Automations](https://pi-web.dev/automations) - [Configuration](https://pi-web.dev/config) - [Plugins](https://pi-web.dev/plugins) - [FAQ](https://pi-web.dev/faq) diff --git a/docs/assets/workspace-automations.png b/docs/assets/workspace-automations.png new file mode 100644 index 00000000..052ba827 Binary files /dev/null and b/docs/assets/workspace-automations.png differ diff --git a/docs/automations.html b/docs/automations.html new file mode 100644 index 00000000..c0f9801f --- /dev/null +++ b/docs/automations.html @@ -0,0 +1,145 @@ + + +
+ + +Durable agent work
+Create workspace automations with explicit models and deadlines, then inspect which jobs ran, how long they took, and how many tokens and estimated dollars they consumed.
+Select a machine, project, and workspace, then open Automations in the workspace panel. New definitions are saved disabled. Use Run now to test the exact revision before enabling its schedule.
+Editing a job creates a new revision, pauses it, and requires another completed manual test. Each run gets a fresh, independent Pi session.
+Automations belong to the machine that owns the selected workspace. For a federated machine, the controlling gateway forwards automation requests while the remote pi-web-sessiond stores definitions and history, resolves its own models and workspace, and runs the schedule locally.
The browser and controlling gateway may disconnect or restart without stopping the remote schedule. The unversioned automations capability is the stable baseline federation contract; future optional operations will use separate feature capabilities. Definitions are not replicated and do not fail over automatically. To move one, recreate it against a destination workspace so that machine can validate its model and settings.
The same automation never overlaps itself. Colliding scheduled occurrences are recorded as skipped instead of starting another session.
+Every automation card shows its model, provider, thinking policy, and timeout. Fixed models are validated again at dispatch and never silently replaced. Historical runs preserve the actual model and thinking level that were used.
+The default execution timeout is 60 minutes and can be set from one minute through 24 hours. PI WEB records cancellation intent before aborting the Pi session. If a soft abort does not settle within 15 seconds, it force-closes the runtime.
+Runs become unknown when PI WEB cannot prove termination. Cancellation cannot undo external effects that already completed.
The workspace view includes a run timeline, median and p95 duration, outcomes, root-session tokens, estimated spend, per-automation usage totals, and links to resulting sessions.
+Costs are model-pricing estimates unless a provider reports an authoritative charge. Unknown and partial usage stays visibly unknown rather than becoming zero. Current totals cover the fresh root session and do not silently include untracked subagents or paid external tools.
+Definitions, revisions, occurrences, attempts, cancellation, and terminal usage live in automations.sqlite under $PI_WEB_DATA_DIR. The daemon acquires an exclusive runtime-owner record before listening or recovering work, so a second live daemon using the same database refuses to start. Browser and web/API restarts reconstruct state from the long-lived session daemon.
After an unexpected daemon restart, queued runs remain eligible, while ambiguous starting or running attempts become unknown and are never blindly repeated.
+The unreleased schedule-plugin prototype's .pi-web/scheduled-sessions.json format is not imported because it lacks the revision, attempt, cancellation, and accounting records required by this runtime. Development-branch prototype definitions must be recreated.
A fresh session isolates conversational context, not the host. Automations run with the target machine's Pi configuration and OS-user permissions. PI WEB revalidates the registered project and workspace before dispatch, but prompts and tools can still exercise the permissions granted to Pi.
+ ++ Schedule fresh Pi sessions with fixed models and hard timeouts, then inspect run duration, tokens, and + estimated cost from the workspace Automations view. +
+ Explore automations → +@@ -382,6 +391,7 @@
Select a workspace to manage automations.
`; + const terminalRuns = this.runs.filter((run) => !ACTIVE_STATUSES.includes(run.status)); + const durations = terminalRuns.map(runDurationMs).filter((value): value is number => value !== undefined).sort((a, b) => a - b); + const knownUsages = terminalRuns.flatMap((run) => { + const usage = knownAutomationUsage(run); + return usage === undefined ? [] : [usage]; + }); + const usagesWithCost = knownUsages.filter((usage) => usage.estimatedCostMicros !== undefined); + const totalTokens = knownUsages.reduce((sum, usage) => sum + usage.tokens.total, 0); + const knownCostMicros = usagesWithCost.reduce((sum, usage) => sum + (usage.estimatedCostMicros ?? 0), 0); + const attention = terminalRuns.filter((run) => run.status === "failed" || run.status === "timed_out" || run.status === "unknown").length; + + return html` +Fresh Pi sessions scheduled and supervised by this machine.
+Loading automations…
` : this.definitions.length === 0 ? html`No automations yet. Create a disabled draft, run it once, then enable its schedule.
` : html` +${definition.description}
`} +Runs will appear here with their elapsed time and outcome.
Usage is captured when a run settles.
` : html` +| Automation | Runs | Tokens | Estimated cost |
|---|---|---|---|
| ${entry.name} | ${String(entry.runs)} | +${entry.tokens === undefined ? "unknown" : html` ${formatTokenCount(entry.tokens)}`}${entry.unknownTokens > 0 ? ` · ${String(entry.unknownTokens)} unknown` : ""} |
+ ${entry.costMicros === undefined ? "unknown" : formatCost(entry.costMicros / 1_000_000)}${entry.unknownCost > 0 ? ` · ${String(entry.unknownCost)} unknown` : ""} | +
No runs yet.
` : html` +| Automation | Status | Queued | Started | Completed | Duration | Model | Root tokens | Estimated cost | Action |
|---|---|---|---|---|---|---|---|---|---|
| ${run.automationName}${run.source} · r${String(run.automationRevision)} | +${run.status.replaceAll("_", " ")}${run.reason === undefined ? nothing : html`${run.reason.replaceAll("_", " ")}`}${run.error === undefined ? nothing : html`${run.error}`} | +${formatDateTime(run.queuedAt)} | +${run.startedAt === undefined ? "Not started" : formatDateTime(run.startedAt)} | +${run.completedAt === undefined ? "—" : formatDateTime(run.completedAt)} | +${formatDuration(runDurationMs(run))} | +${run.actualModel === undefined ? modelLabel(run.configuredModel) : sessionModelLabel(run.actualModel)}${run.actualThinkingLevel === undefined ? "" : ` · ${run.actualThinkingLevel}`} | +${usage === undefined ? "unknown" : html`${formatTokenCount(usage.tokens.total)}${usage.quality.replaceAll("_", " ")} · ${tokenBreakdown(usage.tokens)}`} | +${usage?.estimatedCostMicros === undefined ? "unknown" : `est. ${formatCost(usage.estimatedCostMicros / 1_000_000)}`} | +${ACTIVE_STATUSES.includes(run.status) + ? html`` + : run.sessionId === undefined ? "—" : html`Open session`} + | +