paseo-form-render 0.0.0-stage → 0.2.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.
@@ -0,0 +1,27 @@
1
+ import type { PluginRpcContract } from "@getpaseo/plugin";
2
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
3
+ import { useRpc } from "@getpaseo/plugin/client";
4
+ import React, { createContext, useContext } from "react";
5
+ import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
6
+
7
+ export type PfrRpcFn = PluginClientContext["rpc"];
8
+
9
+ const PfrRpcContext = createContext<PfrRpcFn | null>(null);
10
+
11
+ export const PfrRpcProvider: React.FC<{ rpc?: PfrRpcFn; children: React.ReactNode }> = ({
12
+ rpc,
13
+ children,
14
+ }) => {
15
+ if (!rpc) return <>{children}</>;
16
+ return <PfrRpcContext.Provider value={rpc}>{children}</PfrRpcContext.Provider>;
17
+ };
18
+
19
+ export function usePfrRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
20
+ contract: PluginRpcContract<InputSchema, OutputSchema>,
21
+ ): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
22
+ const customRpc = useContext(PfrRpcContext);
23
+ if (customRpc) {
24
+ return (input: ZodInput<InputSchema>) => customRpc(contract, input);
25
+ }
26
+ return useRpc(contract);
27
+ }
@@ -0,0 +1,163 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useSettings, useTranslation } from "@getpaseo/plugin/client";
3
+ import React from "react";
4
+ import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from "react-native";
5
+ import { MAX_AUTO_FIX_RETRIES, pfrSettings } from "../shared/settings";
6
+ import { messages } from "./messages";
7
+
8
+ export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
9
+ const settings = useSettings(pfrSettings);
10
+ const { t } = useTranslation(messages);
11
+ const { colors } = theme;
12
+
13
+ if (settings.status === "loading") {
14
+ return (
15
+ <View style={styles.container}>
16
+ <ActivityIndicator color={colors.accent} />
17
+ </View>
18
+ );
19
+ }
20
+
21
+ if (settings.status === "error" || settings.status === "invalid") {
22
+ return (
23
+ <View style={styles.container}>
24
+ <Text style={[styles.errorText, { color: colors.statusDanger }]}>
25
+ {t("settings.loadError", { error: settings.error })}
26
+ </Text>
27
+ <Pressable
28
+ style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
29
+ onPress={() => void settings.reload()}
30
+ >
31
+ <Text style={[styles.stepText, { color: colors.foreground }]}>{t("settings.retry")}</Text>
32
+ </Pressable>
33
+ {settings.status === "invalid" && (
34
+ <Pressable
35
+ style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
36
+ onPress={() => void settings.reset()}
37
+ >
38
+ <Text style={[styles.stepText, { color: colors.foreground }]}>{t("settings.resetDefaults")}</Text>
39
+ </Pressable>
40
+ )}
41
+ </View>
42
+ );
43
+ }
44
+
45
+ const value = settings.values.autoFixMaxRetries;
46
+ const change = (next: number) => {
47
+ if (next < 0 || next > MAX_AUTO_FIX_RETRIES || settings.saving) return;
48
+ void settings.save({ ...settings.values, autoFixMaxRetries: next }, settings.revision);
49
+ };
50
+ const stepStyle = (disabled: boolean) => [
51
+ styles.stepButton,
52
+ { borderColor: colors.border, backgroundColor: colors.surface2 },
53
+ disabled && { opacity: 0.4 },
54
+ ];
55
+
56
+ return (
57
+ <View style={styles.container}>
58
+ <Text style={[styles.title, { color: colors.foreground }]}>{t("settings.autoFixTitle")}</Text>
59
+ <Text style={[styles.description, { color: colors.foregroundMuted }]}>{t("settings.autoFixDescription")}</Text>
60
+
61
+ <View style={styles.row}>
62
+ <Text style={[styles.label, { color: colors.foreground }]}>{t("settings.maxRetries")}</Text>
63
+ <View style={styles.stepper}>
64
+ <Pressable
65
+ accessibilityLabel={t("settings.decreaseRetries")}
66
+ disabled={value <= 0 || settings.saving}
67
+ style={stepStyle(value <= 0 || settings.saving)}
68
+ onPress={() => change(value - 1)}
69
+ >
70
+ <Text style={[styles.stepText, { color: colors.foreground }]}>−</Text>
71
+ </Pressable>
72
+ <Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
73
+ <Pressable
74
+ accessibilityLabel={t("settings.increaseRetries")}
75
+ disabled={value >= MAX_AUTO_FIX_RETRIES || settings.saving}
76
+ style={stepStyle(value >= MAX_AUTO_FIX_RETRIES || settings.saving)}
77
+ onPress={() => change(value + 1)}
78
+ >
79
+ <Text style={[styles.stepText, { color: colors.foreground }]}>+</Text>
80
+ </Pressable>
81
+ </View>
82
+ </View>
83
+
84
+ <Text style={[styles.hint, { color: colors.foregroundMuted }]}>
85
+ {value === 0 ? t("settings.hintOff") : t("settings.hint", { count: value })}
86
+ {settings.saving ? ` ${t("settings.saving")}` : ""}
87
+ </Text>
88
+
89
+ <Text style={[styles.title, { color: colors.foreground }]}>{t("settings.preferFormsTitle")}</Text>
90
+ <Text style={[styles.description, { color: colors.foregroundMuted }]}>{t("settings.preferFormsDescription")}</Text>
91
+ <View style={styles.row}>
92
+ <Text style={[styles.label, { color: colors.foreground }]}>{t("settings.requireForms")}</Text>
93
+ <Switch
94
+ accessibilityLabel={t("settings.requireForms")}
95
+ value={settings.values.preferForms}
96
+ disabled={settings.saving}
97
+ onValueChange={(next) => void settings.save({ ...settings.values, preferForms: next }, settings.revision)}
98
+ />
99
+ </View>
100
+ {settings.saveError && (
101
+ <Text style={[styles.errorText, { color: colors.statusDanger }]}>{t("settings.saveError", { error: settings.saveError })}</Text>
102
+ )}
103
+ </View>
104
+ );
105
+ };
106
+
107
+ const styles = StyleSheet.create({
108
+ container: {
109
+ padding: 16,
110
+ gap: 12,
111
+ },
112
+ title: {
113
+ fontSize: 16,
114
+ fontWeight: "600",
115
+ },
116
+ description: {
117
+ fontSize: 13,
118
+ lineHeight: 18,
119
+ },
120
+ row: {
121
+ flexDirection: "row",
122
+ alignItems: "center",
123
+ justifyContent: "space-between",
124
+ },
125
+ label: {
126
+ fontSize: 14,
127
+ fontWeight: "500",
128
+ },
129
+ stepper: {
130
+ flexDirection: "row",
131
+ alignItems: "center",
132
+ gap: 12,
133
+ },
134
+ stepButton: {
135
+ width: 36,
136
+ height: 36,
137
+ borderRadius: 6,
138
+ borderWidth: 1,
139
+ alignItems: "center",
140
+ justifyContent: "center",
141
+ },
142
+ retryButton: {
143
+ width: undefined,
144
+ alignSelf: "flex-start",
145
+ paddingHorizontal: 12,
146
+ },
147
+ stepText: {
148
+ fontSize: 16,
149
+ fontWeight: "600",
150
+ },
151
+ value: {
152
+ fontSize: 16,
153
+ fontWeight: "600",
154
+ minWidth: 20,
155
+ textAlign: "center",
156
+ },
157
+ hint: {
158
+ fontSize: 12,
159
+ },
160
+ errorText: {
161
+ fontSize: 12,
162
+ },
163
+ });
@@ -0,0 +1,103 @@
1
+ import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
+ import { usePaseo } from "@getpaseo/plugin/client";
3
+ import React from "react";
4
+ import { Platform } from "react-native";
5
+ import { z } from "zod";
6
+ import { hasFormBlock } from "./shared/parser";
7
+ import { MessageContainer } from "./client/container";
8
+ import { messages } from "./client/messages";
9
+ import { PfrSettingsScreen } from "./client/settings-screen";
10
+ import { showDemoForm } from "./shared/demo";
11
+ import { PFR_PROMPT_INSTRUCTION } from "./shared/prompt";
12
+
13
+ const timelineKind = "pfr-form";
14
+ const timelineDataSchema = z.object({
15
+ text: z.string(),
16
+ });
17
+
18
+ export default function contribute(client: PluginClientContext) {
19
+ function TimelineMessageView({
20
+ item,
21
+ agentId,
22
+ theme,
23
+ }: PluginTimelineItemProps<z.output<typeof timelineDataSchema>>) {
24
+ const paseo = usePaseo();
25
+
26
+ const handleSubmit = async (formattedMessage: string) => {
27
+ await paseo.agents.ref(agentId).send(formattedMessage);
28
+ };
29
+
30
+ return (
31
+ <MessageContainer
32
+ text={item.data.text}
33
+ theme={theme}
34
+ agentId={agentId}
35
+ rpc={client.rpc}
36
+ onSubmit={handleSubmit}
37
+ />
38
+ );
39
+ }
40
+ // Transform assistant messages that include pfr-form fences across all platforms (web & mobile).
41
+ client.addTimelineTransformer({
42
+ id: "pfr-transformer",
43
+ query: { itemType: "assistant_message" },
44
+ transform({ item }) {
45
+ if (!hasFormBlock(item.text)) {
46
+ return;
47
+ }
48
+ return {
49
+ items: [
50
+ {
51
+ type: "plugin",
52
+ kind: timelineKind,
53
+ version: 1,
54
+ data: {
55
+ text: item.text,
56
+ },
57
+ },
58
+ ],
59
+ };
60
+ },
61
+ });
62
+
63
+ // Render plugin timeline items
64
+ client.addTimelineRenderer({
65
+ kind: timelineKind,
66
+ version: 1,
67
+ schema: timelineDataSchema,
68
+ Component: TimelineMessageView,
69
+ });
70
+
71
+ // Host-level settings, including the automatic fix retry limit
72
+ client.addSettingsScreen({
73
+ id: "paseo-form-render-settings",
74
+ title: "Paseo Form Render",
75
+ icon: "Settings",
76
+ Component: PfrSettingsScreen,
77
+ });
78
+
79
+ // Slash command to enable pfr-form in an existing agent session
80
+ client.addSlashCommand({
81
+ name: "form",
82
+ description: messages.message("slash.form"),
83
+ argumentHint: "[task]",
84
+ context: "agent",
85
+ async onSubmit({ args, paseo, agent }) {
86
+ const task = args || "Please enable the pfr-form protocol and present interactive forms when applicable.";
87
+ await paseo.agents.ref(agent.id).send(`${PFR_PROMPT_INSTRUCTION}\n\n${task}`);
88
+ },
89
+ });
90
+
91
+ // Slash command to show a sample form
92
+ client.addSlashCommand({
93
+ name: "form-demo",
94
+ description: messages.message("slash.formDemo"),
95
+ argumentHint: "",
96
+ context: "agent",
97
+ async onSubmit({ rpc, agent }) {
98
+ await rpc(showDemoForm, { agentId: agent.id });
99
+ },
100
+ });
101
+
102
+ return () => {};
103
+ }
@@ -0,0 +1,38 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { registerAutoFix } from "./server/auto-fix";
3
+ import { appendDemoForm } from "./server/demo";
4
+ import { readPfrSettings, registerPfrSettings } from "./server/settings";
5
+ import { handleListProjectFiles, handleCreateDirectory } from "./server/file-picker";
6
+ import { showDemoForm } from "./shared/demo";
7
+ import { listProjectFilesRpc, createDirectoryRpc } from "./shared/pickers";
8
+ import { buildPromptInstruction } from "./shared/prompt";
9
+
10
+ export default function contribute(server: PluginServerContext) {
11
+ const settings = registerPfrSettings(server);
12
+
13
+ // Inject instructions when a new agent is created
14
+ server.before("agent.create", async ({ request }) => {
15
+ const previous = request.config.systemPrompt ?? "";
16
+ if (previous.includes("[paseo-form-render]")) {
17
+ return;
18
+ }
19
+ const { preferForms } = await readPfrSettings(settings);
20
+ return {
21
+ ...request,
22
+ config: {
23
+ ...request.config,
24
+ systemPrompt: [previous, buildPromptInstruction({ preferForms })].filter(Boolean).join("\n\n"),
25
+ },
26
+ };
27
+ });
28
+
29
+ server.handle(showDemoForm, appendDemoForm);
30
+ server.handle(listProjectFilesRpc, handleListProjectFiles);
31
+ server.handle(createDirectoryRpc, handleCreateDirectory);
32
+
33
+ const stopAutoFix = registerAutoFix(server, settings);
34
+
35
+ return () => {
36
+ stopAutoFix();
37
+ };
38
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "paseo-form-render",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Interactive forms, dashboards, and metric displays in Paseo conversations using pfr-form code fences.",
5
+ "license": "MIT",
6
+ "files": [
7
+ "README.md",
8
+ "LICENSE",
9
+ "paseo-plugin.json",
10
+ "index.client.tsx",
11
+ "index.server.ts",
12
+ "client/",
13
+ "server/",
14
+ "shared/",
15
+ "!**/*.test.ts"
16
+ ],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "vitest run",
20
+ "pack:offline": "node scripts/pack-offline.mjs",
21
+ "e2e": "npm --prefix e2e run e2e --"
22
+ },
23
+ "devDependencies": {
24
+ "@getpaseo/client": "^0.13.0",
25
+ "@getpaseo/plugin": "^0.13.0",
26
+ "@tanstack/react-query": "^5.90.11",
27
+ "@types/node": "^22.20.4",
28
+ "@types/react": "~19.2.0",
29
+ "react": "19.1.0",
30
+ "react-native": "0.81.5",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^4.1.11",
33
+ "zod": "^4.4.3"
34
+ },
35
+ "keywords": [
36
+ "paseo",
37
+ "paseo-plugin",
38
+ "forms",
39
+ "dashboards",
40
+ "interactive-ui",
41
+ "form-render"
42
+ ],
43
+ "author": "Weiqiang",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/quickwind/paseo-form-render.git"
47
+ },
48
+ "homepage": "https://github.com/quickwind/paseo-form-render#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/quickwind/paseo-form-render/issues"
51
+ }
52
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "paseo-form-render",
3
+ "name": "Paseo Form Render",
4
+ "description": "Render interactive forms, metrics, and dashboards in conversations using pfr-form code blocks",
5
+ "requirements": {
6
+ "paseo": ">=0.13.0"
7
+ }
8
+ }
@@ -0,0 +1,217 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import {
3
+ buildFixMessage,
4
+ collectTurnFormFailures,
5
+ countTrailingFixAttempts,
6
+ type FormFailure,
7
+ type TimelineEntry,
8
+ } from "../shared/form-fix";
9
+ import { readPfrSettings, type PfrSettingsHandle } from "./settings";
10
+
11
+ /** How many of the newest timeline entries to fetch when checking the conversation history. */
12
+ const HISTORY_LIMIT = 300;
13
+
14
+ export type AutoFixSkipReason =
15
+ | "not-completed"
16
+ | "no-failures"
17
+ | "disabled"
18
+ | "history-unavailable"
19
+ | "history-truncated"
20
+ | "superseded"
21
+ | "limit-reached"
22
+ | "busy"
23
+ | "status-unavailable";
24
+
25
+ export type AutoFixPlan = { ok: false; reason: AutoFixSkipReason } | { ok: true; failures: FormFailure[] };
26
+
27
+ export type AutoFixDecision =
28
+ | { send: false; reason: AutoFixSkipReason }
29
+ | { send: true; message: string; attempt: number };
30
+
31
+ export interface TurnInput {
32
+ /** The `timeline` of the `agent.turn_ended` event. It may cover only the finished turn. */
33
+ turnTimeline: readonly TimelineEntry[];
34
+ outcome: { readonly kind: string };
35
+ /** Maximum consecutive fix requests; 0 disables auto-fix. */
36
+ maxRetries: number;
37
+ }
38
+
39
+ export interface AutoFixInput extends TurnInput {
40
+ /** The newest part of the conversation, fetched just before sending. Missing or null means it could not be read. */
41
+ latestTimeline?: readonly TimelineEntry[] | null;
42
+ /** True when older entries exist beyond `latestTimeline`. */
43
+ latestTruncated?: boolean;
44
+ }
45
+
46
+ /** Cheap first gate that needs only the event: is there anything to fix, and is fixing on? */
47
+ export function planAutoFix({ turnTimeline, outcome, maxRetries }: TurnInput): AutoFixPlan {
48
+ if (outcome.kind !== "completed") return { ok: false, reason: "not-completed" };
49
+ const failures = collectTurnFormFailures(turnTimeline);
50
+ if (failures.length === 0) return { ok: false, reason: "no-failures" };
51
+ if (maxRetries <= 0) return { ok: false, reason: "disabled" };
52
+ return { ok: true, failures };
53
+ }
54
+
55
+ function isRealUserMessage(entry: TimelineEntry): boolean {
56
+ return entry.type === "user_message" && countTrailingFixAttempts([entry]) === 0;
57
+ }
58
+
59
+ /**
60
+ * Decides whether to ask the agent to repair its pfr-form blocks. Failures come from the finished
61
+ * turn; the retry count and "did anything newer happen" checks use the freshly fetched history,
62
+ * and the answer is never to send when that history is unavailable.
63
+ */
64
+ export function decideAutoFix(input: AutoFixInput): AutoFixDecision {
65
+ const plan = planAutoFix(input);
66
+ if (!plan.ok) return { send: false, reason: plan.reason };
67
+
68
+ const latest = input.latestTimeline;
69
+ if (!latest) return { send: false, reason: "history-unavailable" };
70
+
71
+ // A user message after the last assistant output means the conversation moved on since this turn
72
+ // ended (the user typed something, or a fix request is already on its way).
73
+ const lastAssistant = lastIndexOf(latest, (e) => e.type === "assistant_message");
74
+ const lastUser = lastIndexOf(latest, (e) => e.type === "user_message");
75
+ if (lastAssistant < 0 || lastUser > lastAssistant) return { send: false, reason: "superseded" };
76
+
77
+ // If the finished turn shows the message that started it, the newest user message in the history
78
+ // must be that same message; anything else is a newer request that has already been answered.
79
+ // A fetched window with no user message at all holds only this turn's tail, so nothing newer
80
+ // arrived (a newer message would sit at the end of the window).
81
+ const turnLastUser = lastIndexOf(input.turnTimeline, (e) => e.type === "user_message");
82
+ if (lastUser >= 0 && turnLastUser >= 0 && latest[lastUser].text !== input.turnTimeline[turnLastUser].text) {
83
+ return { send: false, reason: "superseded" };
84
+ }
85
+
86
+ // Count from the fetched history; when a long turn pushed every real user message out of that
87
+ // window, fall back to the event timeline, which may hold the full history.
88
+ let countFrom: readonly TimelineEntry[] = latest;
89
+ if (input.latestTruncated && lastIndexOf(latest, isRealUserMessage) < 0) {
90
+ if (lastIndexOf(input.turnTimeline, isRealUserMessage) < 0) return { send: false, reason: "history-truncated" };
91
+ countFrom = input.turnTimeline;
92
+ }
93
+
94
+ const attempts = countTrailingFixAttempts(countFrom);
95
+ if (attempts >= input.maxRetries) return { send: false, reason: "limit-reached" };
96
+
97
+ return { send: true, message: buildFixMessage(plan.failures), attempt: attempts + 1 };
98
+ }
99
+
100
+ function lastIndexOf(entries: readonly TimelineEntry[], predicate: (entry: TimelineEntry) => boolean): number {
101
+ for (let i = entries.length - 1; i >= 0; i--) {
102
+ if (predicate(entries[i])) return i;
103
+ }
104
+ return -1;
105
+ }
106
+
107
+ /**
108
+ * Reads timeline items out of a `timeline.refetch()` payload (`entries[].item`, `hasOlder`, `error`).
109
+ * Returns null when the payload reports an error or is not shaped as expected.
110
+ */
111
+ export function timelineFromPayload(payload: unknown): { items: TimelineEntry[]; truncated: boolean } | null {
112
+ if (typeof payload !== "object" || payload === null) return null;
113
+ const { entries, error, hasOlder } = payload as { entries?: unknown; error?: unknown; hasOlder?: unknown };
114
+ if (error || !Array.isArray(entries)) return null;
115
+
116
+ const items: TimelineEntry[] = [];
117
+ for (const entry of entries) {
118
+ const item = (entry as { item?: unknown } | null)?.item as { type?: unknown; text?: unknown } | undefined;
119
+ if (!item || typeof item.type !== "string") return null;
120
+ items.push({ type: item.type, text: typeof item.text === "string" ? item.text : undefined });
121
+ }
122
+ return { items, truncated: hasOlder === true };
123
+ }
124
+
125
+ /** Skip reason when the agent is already busy, or its status cannot be read; null when it is idle. */
126
+ export function idleGate(status: string | null | undefined): "busy" | "status-unavailable" | null {
127
+ if (!status) return "status-unavailable";
128
+ return status === "idle" ? null : "busy";
129
+ }
130
+
131
+ async function checkAgentIdle(agent: {
132
+ refresh(): Promise<unknown>;
133
+ readonly status: string | null;
134
+ }): Promise<"busy" | "status-unavailable" | null> {
135
+ try {
136
+ await agent.refresh();
137
+ } catch (error) {
138
+ console.warn("[paseo-form-render] could not refresh agent status", error);
139
+ return "status-unavailable";
140
+ }
141
+ return idleGate(agent.status);
142
+ }
143
+
144
+ /** Registers the turn-end hook that sends automatic fix requests. */
145
+ export function registerAutoFix(server: PluginServerContext, settings: PfrSettingsHandle) {
146
+ async function readMaxRetries(): Promise<number> {
147
+ return (await readPfrSettings(settings)).autoFixMaxRetries;
148
+ }
149
+
150
+ /** The newest history of the agent, or null when it cannot be read (the caller then sends nothing). */
151
+ async function readTail(agent: {
152
+ timeline: { refetch(options: { direction: "tail"; limit: number; projection: "projected" }): Promise<unknown> };
153
+ }): Promise<TimelineEntry[] | null> {
154
+ try {
155
+ const history = timelineFromPayload(
156
+ await agent.timeline.refetch({ direction: "tail", limit: HISTORY_LIMIT, projection: "projected" }),
157
+ );
158
+ return history?.items ?? null;
159
+ } catch (error) {
160
+ console.warn("[paseo-form-render] could not fetch agent history, skipping auto-fix", error);
161
+ return null;
162
+ }
163
+ }
164
+
165
+ return server.on("agent.turn_ended", async (event, { paseo, signal }) => {
166
+ try {
167
+ const agent = paseo.agents.ref(event.agent.id);
168
+ // Paseo 0.13 sends an always-empty `timeline` on this event (deprecated), so the finished turn is read from history.
169
+ const turnTimeline = event.timeline.length > 0 ? event.timeline : await readTail(agent);
170
+ if (!turnTimeline) return;
171
+ const turn: TurnInput = {
172
+ turnTimeline,
173
+ outcome: event.outcome,
174
+ maxRetries: await readMaxRetries(),
175
+ };
176
+ if (!planAutoFix(turn).ok) return;
177
+
178
+ // Yield first so the send never runs re-entrantly inside the turn-end dispatch. The hook stays
179
+ // pending until the send settles because its `paseo` handle may be scoped to this invocation.
180
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
181
+ if (signal.aborted) return;
182
+
183
+ let history: ReturnType<typeof timelineFromPayload> = null;
184
+ try {
185
+ history = timelineFromPayload(
186
+ await agent.timeline.refetch({ direction: "tail", limit: HISTORY_LIMIT, projection: "projected" }),
187
+ );
188
+ } catch (error) {
189
+ console.warn("[paseo-form-render] could not fetch agent history, skipping auto-fix", error);
190
+ return;
191
+ }
192
+
193
+ const decision = decideAutoFix({
194
+ ...turn,
195
+ latestTimeline: history?.items ?? null,
196
+ latestTruncated: history?.truncated,
197
+ });
198
+ if (!decision.send) {
199
+ if (decision.reason === "history-unavailable" || decision.reason === "history-truncated") {
200
+ console.warn(`[paseo-form-render] skipping auto-fix: ${decision.reason}`);
201
+ }
202
+ return;
203
+ }
204
+ // `send` interrupts a running turn, so a message that arrived after the history fetch would be
205
+ // cancelled. Re-read the status right before sending and leave a running agent alone. A small
206
+ // window remains: Paseo has no "send only if idle" option (see README).
207
+ const gate = await checkAgentIdle(agent);
208
+ if (gate) {
209
+ if (gate === "status-unavailable") console.warn("[paseo-form-render] skipping auto-fix: status-unavailable");
210
+ return;
211
+ }
212
+ await agent.send(decision.message);
213
+ } catch (error) {
214
+ console.warn("[paseo-form-render] auto-fix hook failed", error);
215
+ }
216
+ });
217
+ }
package/server/demo.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { RpcInput } from "@getpaseo/plugin";
2
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
3
+ import { DEMO_FORM_TEXT, TIMELINE_KIND, type showDemoForm } from "../shared/demo";
4
+
5
+ type Paseo = Parameters<Parameters<PluginServerContext["handle"]>[1]>[1]["paseo"];
6
+
7
+ /** Adds the demo form as a row the form renderer draws; the agent is not prompted. */
8
+ export async function appendDemoForm(
9
+ { agentId }: RpcInput<typeof showDemoForm>,
10
+ { paseo }: { paseo: Paseo },
11
+ ) {
12
+ await paseo.agents.ref(agentId).timeline.append({
13
+ type: "plugin",
14
+ id: `pfr-form-demo-${Date.now()}`,
15
+ kind: TIMELINE_KIND,
16
+ version: 1,
17
+ data: { text: DEMO_FORM_TEXT },
18
+ });
19
+ return {};
20
+ }