@jameslovespancakes/pi-plus 1.0.19 → 1.0.21
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 +133 -246
- package/package.json +3 -2
- package/src/core/claude-remote/LICENSE.md +22 -0
- package/src/core/claude-remote/UPSTREAM.md +40 -0
- package/src/core/claude-remote/bridge.ts +392 -0
- package/src/core/claude-remote/protocol.ts +83 -0
- package/src/core/env.ts +7 -1
- package/src/domains/claude-remote/auth.ts +25 -0
- package/src/domains/claude-remote/index.ts +183 -0
- package/src/domains/claude-remote/picker.ts +36 -0
- package/src/domains/models/provider-picker.ts +3 -46
- package/src/domains/setup/index.ts +12 -1
- package/src/domains/workflows/index.ts +56 -104
- package/src/domains/workflows/runtime/advisory-challenge.ts +3 -3
- package/src/domains/workflows/runtime/agent-attempt.ts +3 -3
- package/src/domains/workflows/runtime/agent-options.ts +18 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +7 -3
- package/src/domains/workflows/runtime/agent-runner.ts +14 -6
- package/src/domains/workflows/runtime/agent-session.ts +34 -3
- package/src/domains/workflows/runtime/cancellation.ts +5 -0
- package/src/domains/workflows/runtime/engine.ts +19 -40
- package/src/domains/workflows/runtime/journal.ts +4 -4
- package/src/domains/workflows/runtime/live-agent.ts +37 -0
- package/src/domains/workflows/runtime/model-profiles.ts +2 -6
- package/src/domains/workflows/runtime/progress-types.ts +3 -1
- package/src/domains/workflows/runtime/progress.ts +69 -42
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +3 -3
- package/src/domains/workflows/runtime/types.ts +16 -15
- package/src/domains/workflows/runtime/ui/agent-transcript.ts +59 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +6 -2
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +75 -61
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +26 -66
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +5 -5
- package/src/domains/workflows/runtime/{background-workflows.ts → workflow-lifecycle.ts} +91 -75
- package/src/domains/workflows/runtime/workflow-management.ts +66 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +16 -20
- package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts} +3 -3
- package/src/domains/workflows/runtime/workflow-run-record.ts +7 -4
- package/src/domains/workflows/workflows/code-review.ts +1 -1
- package/src/domains/workflows/workflows/diagnose.ts +1 -1
- package/src/domains/workflows/workflows/perf-review.ts +1 -1
- package/src/domains/workflows/workflows/refactor-scout.ts +1 -1
- package/src/domains/workflows/workflows/research.ts +4 -4
- package/src/ui/settings-picker.ts +26 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +0 -75
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ClaudeRemoteBridge, type BridgeOptions } from "../../core/claude-remote/bridge.ts";
|
|
4
|
+
import { mirrorMessage } from "../../core/claude-remote/protocol.ts";
|
|
5
|
+
import { env, setEnv } from "../../core/env.ts";
|
|
6
|
+
import { createTokenSource } from "./auth.ts";
|
|
7
|
+
import { remoteControlPicker } from "./picker.ts";
|
|
8
|
+
|
|
9
|
+
interface Bridge {
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): void;
|
|
12
|
+
send: ClaudeRemoteBridge["send"];
|
|
13
|
+
reportState: ClaudeRemoteBridge["reportState"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Dependency seams allow lifecycle tests without network access or real credentials. */
|
|
17
|
+
export interface RemoteDependencies {
|
|
18
|
+
bridge(options: BridgeOptions): Bridge;
|
|
19
|
+
tokenSource(): BridgeOptions["getAccessToken"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaults: RemoteDependencies = {
|
|
23
|
+
bridge: (options) => new ClaudeRemoteBridge(options),
|
|
24
|
+
tokenSource: () => createTokenSource(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies = defaults): void {
|
|
28
|
+
let active: Bridge | undefined;
|
|
29
|
+
let status = "off";
|
|
30
|
+
let enabled = false;
|
|
31
|
+
let current: ExtensionContext | undefined;
|
|
32
|
+
let generation = 0;
|
|
33
|
+
// Counts rather than a TTL: follow-ups can wait longer than 30 seconds.
|
|
34
|
+
const echoes: string[] = [];
|
|
35
|
+
|
|
36
|
+
function notify(ctx: ExtensionContext, message: string, warning = false): void {
|
|
37
|
+
ctx.ui.notify(message, warning ? "warning" : "info");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setConnectionStatus(value: string): void {
|
|
41
|
+
status = value;
|
|
42
|
+
if (!current?.hasUI) return;
|
|
43
|
+
const connected = value.startsWith("connected");
|
|
44
|
+
const label = connected ? (value.includes("read-only") ? "Read-only" : "Active")
|
|
45
|
+
: value === "connecting" ? "Connecting" : "Offline";
|
|
46
|
+
current.ui.setStatus("claude-remote",
|
|
47
|
+
current.ui.theme.fg(connected ? "success" : "error", `● Remote Control ${label}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stop(): void {
|
|
51
|
+
++generation;
|
|
52
|
+
active?.stop();
|
|
53
|
+
active = undefined;
|
|
54
|
+
echoes.length = 0;
|
|
55
|
+
setConnectionStatus("off");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function start(ctx: ExtensionContext): void {
|
|
59
|
+
if (active) {
|
|
60
|
+
notify(ctx, `Claude Remote: ${status}. Open https://claude.ai/code`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
current = ctx;
|
|
64
|
+
const gen = ++generation;
|
|
65
|
+
const title = `pi: ${pi.getSessionName() || basename(ctx.cwd) || "session"}`.slice(0, 100);
|
|
66
|
+
setConnectionStatus("connecting");
|
|
67
|
+
try {
|
|
68
|
+
const allowInbound = !/^(0|false|off|no)$/i.test(env("PI_CLAUDE_REMOTE_ALLOW_INBOUND") ?? "1");
|
|
69
|
+
const bridge = deps.bridge({
|
|
70
|
+
title, allowInbound,
|
|
71
|
+
trustedDeviceToken: env("CLAUDE_TRUSTED_DEVICE_TOKEN"),
|
|
72
|
+
getAccessToken: deps.tokenSource(),
|
|
73
|
+
onText(text) {
|
|
74
|
+
if (generation !== gen || !current || !allowInbound) return;
|
|
75
|
+
if (echoes.length >= 256) throw new Error("Too many remote follow-ups");
|
|
76
|
+
echoes.push(text);
|
|
77
|
+
try {
|
|
78
|
+
pi.sendUserMessage(text, current.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
echoes.splice(echoes.lastIndexOf(text), 1);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
onInterrupt() {
|
|
85
|
+
if (generation === gen && allowInbound) current?.abort();
|
|
86
|
+
},
|
|
87
|
+
onConnect(id) {
|
|
88
|
+
if (generation !== gen) return;
|
|
89
|
+
setConnectionStatus(allowInbound ? "connected" : "connected (read-only)");
|
|
90
|
+
notify(ctx, `Claude Remote: ${title} is live at https://claude.ai/code (${id}).`);
|
|
91
|
+
},
|
|
92
|
+
onConnectionChange(connected) {
|
|
93
|
+
if (generation !== gen) return;
|
|
94
|
+
setConnectionStatus(connected ? (allowInbound ? "connected" : "connected (read-only)") : "connecting");
|
|
95
|
+
},
|
|
96
|
+
onError(message) {
|
|
97
|
+
if (generation !== gen) return;
|
|
98
|
+
stop();
|
|
99
|
+
setConnectionStatus("disconnected");
|
|
100
|
+
notify(ctx, `${message}. Pi continues locally.`, true);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
active = bridge;
|
|
104
|
+
bridge.reportState(ctx.isIdle() ? "idle" : "running");
|
|
105
|
+
void bridge.start().catch(() => {
|
|
106
|
+
if (generation !== gen) return;
|
|
107
|
+
stop();
|
|
108
|
+
setConnectionStatus("disconnected");
|
|
109
|
+
notify(ctx, "Claude Remote could not connect. Run /login for Anthropic, then /claude-remote on.", true);
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
stop();
|
|
113
|
+
notify(ctx, "Claude Remote needs an Anthropic OAuth login. Use /login, then /claude-remote on.", true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setEnabled(next: boolean, ctx: ExtensionContext): boolean {
|
|
118
|
+
enabled = next;
|
|
119
|
+
const saved = setEnv("PI_CLAUDE_REMOTE", next ? "1" : "0");
|
|
120
|
+
if (next) start(ctx);
|
|
121
|
+
else stop();
|
|
122
|
+
if (!saved) notify(ctx, "Could not save preference; changed this session only.", true);
|
|
123
|
+
else if ((env("PI_CLAUDE_REMOTE") === "1") !== next) {
|
|
124
|
+
notify(ctx, "PI_CLAUDE_REMOTE overrides this preference after reload.", true);
|
|
125
|
+
}
|
|
126
|
+
return enabled;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pi.registerCommand("claude-remote", {
|
|
130
|
+
description: "Remote Control on/off",
|
|
131
|
+
getArgumentCompletions: (prefix) => ["on", "off"]
|
|
132
|
+
.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
133
|
+
handler: async (args, ctx) => {
|
|
134
|
+
const action = args.trim().toLowerCase();
|
|
135
|
+
if (!action && ctx.mode === "tui") {
|
|
136
|
+
await ctx.ui.custom((_tui, theme, _keys, done) => remoteControlPicker(
|
|
137
|
+
theme, enabled, (next) => setEnabled(next, ctx), () => done(undefined),
|
|
138
|
+
));
|
|
139
|
+
} else if (action === "on" || action === "off") {
|
|
140
|
+
if (action === "on" && ctx.hasUI && !enabled && !await ctx.ui.confirm("Enable Remote Control?",
|
|
141
|
+
"Share sessions with Anthropic and control pi from the Claude app. Auto-starts in interactive sessions.")) return;
|
|
142
|
+
setEnabled(action === "on", ctx);
|
|
143
|
+
} else notify(ctx, "Usage: /claude-remote [on|off]", true);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
|
+
stop();
|
|
149
|
+
current = ctx;
|
|
150
|
+
setConnectionStatus("off");
|
|
151
|
+
enabled = env("PI_CLAUDE_REMOTE") === "1";
|
|
152
|
+
// Never spawn remote mirrors for workflow/SDK/print subagents by default.
|
|
153
|
+
if (ctx.mode === "tui" && enabled) start(ctx);
|
|
154
|
+
});
|
|
155
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
156
|
+
stop();
|
|
157
|
+
if (ctx.hasUI) ctx.ui.setStatus("claude-remote", undefined);
|
|
158
|
+
current = undefined;
|
|
159
|
+
});
|
|
160
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
161
|
+
const wasActive = !!active;
|
|
162
|
+
stop();
|
|
163
|
+
current = ctx;
|
|
164
|
+
if (wasActive) start(ctx); // an old branch's remote input must not control a new branch
|
|
165
|
+
});
|
|
166
|
+
pi.on("message_end", (event, ctx) => {
|
|
167
|
+
current = ctx;
|
|
168
|
+
if (!active) return;
|
|
169
|
+
const message = event.message;
|
|
170
|
+
if (message.role === "user") {
|
|
171
|
+
const text = typeof message.content === "string" ? message.content
|
|
172
|
+
: message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
173
|
+
const index = echoes.indexOf(text);
|
|
174
|
+
if (index !== -1) { echoes.splice(index, 1); return; }
|
|
175
|
+
}
|
|
176
|
+
const outbound = mirrorMessage(message);
|
|
177
|
+
if (outbound) active.send(outbound);
|
|
178
|
+
});
|
|
179
|
+
pi.on("agent_start", (_event, ctx) => { current = ctx; active?.reportState("running"); });
|
|
180
|
+
pi.on("agent_end", (_event, ctx) => { current = ctx; active?.reportState("idle"); });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function claudeRemote(pi: ExtensionAPI): void { registerClaudeRemote(pi); }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SettingsList, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { hasTruecolor, levelColor } from "../../ui/format.ts";
|
|
3
|
+
import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
|
|
4
|
+
|
|
5
|
+
/** Same dot, colors and in-place SettingsList toggle as /provider. */
|
|
6
|
+
export function remoteControlPicker(
|
|
7
|
+
theme: any,
|
|
8
|
+
initial: boolean,
|
|
9
|
+
toggle: (enabled: boolean) => boolean,
|
|
10
|
+
done: () => void,
|
|
11
|
+
): Component {
|
|
12
|
+
let enabled = initial;
|
|
13
|
+
const color = (value: boolean, text: string) => hasTruecolor()
|
|
14
|
+
? levelColor(value ? 100 : 0)(text) : theme.fg(value ? "success" : "error", text);
|
|
15
|
+
const label = () => `${color(enabled, "●")} Remote Control`;
|
|
16
|
+
const value = () => color(enabled, enabled ? "On" : "Off");
|
|
17
|
+
const item = {
|
|
18
|
+
id: "remote-control", label: label(), currentValue: value(),
|
|
19
|
+
values: [color(true, "On"), color(false, "Off")],
|
|
20
|
+
};
|
|
21
|
+
const list = new SettingsList([item], 1, settingsTheme(theme), () => {
|
|
22
|
+
enabled = toggle(!enabled);
|
|
23
|
+
item.label = label();
|
|
24
|
+
list.updateValue(item.id, value());
|
|
25
|
+
}, done, { enableSearch: false });
|
|
26
|
+
const frame = frameSettings(theme, list, "Remote Control");
|
|
27
|
+
return {
|
|
28
|
+
...frame,
|
|
29
|
+
invalidate() {
|
|
30
|
+
item.values = [color(true, "On"), color(false, "Off")];
|
|
31
|
+
item.label = label();
|
|
32
|
+
list.updateValue(item.id, value());
|
|
33
|
+
frame.invalidate();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import { hasTruecolor, levelColor } from "../../ui/format.ts";
|
|
3
|
+
import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The `/provider` picker.
|
|
@@ -79,50 +80,6 @@ function labelFor(theme: any, row: ProviderRow): string {
|
|
|
79
80
|
return `${dot} ${name}`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
* The chrome pi uses for `/model` and its other in-chat pickers.
|
|
84
|
-
*
|
|
85
|
-
* Not a box: a full-width accent rule, a bold title, the body, then a closing
|
|
86
|
-
* rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
|
|
87
|
-
* this reads as part of the chat flow rather than as a floating dialog.
|
|
88
|
-
*
|
|
89
|
-
* Input and mouse events pass straight through, so the frame is presentation
|
|
90
|
-
* only and does not disturb the in-place updates.
|
|
91
|
-
*/
|
|
92
|
-
function framed(theme: any, list: any, title: string): Component {
|
|
93
|
-
const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
invalidate: () => list.invalidate?.(),
|
|
97
|
-
handleInput: (data: string) => list.handleInput(data),
|
|
98
|
-
handleMouse: (event: any) => list.handleMouse?.(event),
|
|
99
|
-
render(width: number): string[] {
|
|
100
|
-
const inner = Math.max(1, width);
|
|
101
|
-
// pi pads title and footer by one column; the list renders flush.
|
|
102
|
-
return [
|
|
103
|
-
rule(inner),
|
|
104
|
-
` ${theme.fg("accent", theme.bold(title))}`,
|
|
105
|
-
...list.render(inner),
|
|
106
|
-
rule(inner),
|
|
107
|
-
];
|
|
108
|
-
},
|
|
109
|
-
} as Component;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Theme for the picker. Every callback takes `selected` so the highlighted row
|
|
114
|
-
* can be emphasised without the caller tracking cursor position.
|
|
115
|
-
*/
|
|
116
|
-
function pickerTheme(theme: any) {
|
|
117
|
-
return {
|
|
118
|
-
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
|
119
|
-
value: (text: string, _selected: boolean) => text,
|
|
120
|
-
description: (text: string) => theme.fg("dim", text),
|
|
121
|
-
cursor: theme.fg("accent", "›"),
|
|
122
|
-
hint: (text: string) => theme.fg("dim", text),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
83
|
export interface PickerDeps {
|
|
127
84
|
/** Re-reads the current rows, so the picker never shows stale state. */
|
|
128
85
|
rows: () => Promise<ProviderRow[]>;
|
|
@@ -195,13 +152,13 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
|
|
|
195
152
|
list = new SettingsList(
|
|
196
153
|
items,
|
|
197
154
|
12,
|
|
198
|
-
|
|
155
|
+
settingsTheme(theme),
|
|
199
156
|
onChange,
|
|
200
157
|
() => done(undefined),
|
|
201
158
|
{ enableSearch: false },
|
|
202
159
|
);
|
|
203
160
|
|
|
204
|
-
return
|
|
161
|
+
return frameSettings(theme, list, "Providers") as Component & { dispose?(): void };
|
|
205
162
|
});
|
|
206
163
|
// No `overlay` option: the picker renders inline in the chat flow rather than
|
|
207
164
|
// floating over it.
|
|
@@ -131,6 +131,17 @@ async function inspect(ctx: any): Promise<Feature[]> {
|
|
|
131
131
|
open: "/remote setup",
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
/* Claude app remote control (preference only; never connect from the hub). */
|
|
135
|
+
const claudeRemote = env("PI_CLAUDE_REMOTE") === "1";
|
|
136
|
+
features.push({
|
|
137
|
+
name: "Claude Remote",
|
|
138
|
+
ready: claudeRemote,
|
|
139
|
+
detail: claudeRemote ? "auto-start enabled for interactive sessions" : "opt-in Claude app mirror; requires Anthropic OAuth",
|
|
140
|
+
commands: ["/claude-remote", "/claude-remote on", "/claude-remote off"],
|
|
141
|
+
setup: "/claude-remote",
|
|
142
|
+
open: "/claude-remote",
|
|
143
|
+
});
|
|
144
|
+
|
|
134
145
|
return features;
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -166,7 +177,7 @@ function buildBrief(features: Feature[]): string {
|
|
|
166
177
|
`Config file: ${configPath()}${existsSync(configPath()) ? "" : " (not created yet)"}`,
|
|
167
178
|
"",
|
|
168
179
|
"Write the reply yourself, in chat. Requirements:",
|
|
169
|
-
"1. One short sentence on what pi-plus is:
|
|
180
|
+
"1. One short sentence on what pi-plus is: extensions for subscriptions, models, workflows, and remote collaboration.",
|
|
170
181
|
"2. A compact list of the capabilities, each with one line on what it does and the command to try. Mark which are already working.",
|
|
171
182
|
pending.length > 0
|
|
172
183
|
? `3. Then a short 'Set these up next' section covering ONLY the ones marked NOT SET UP (${pending.map((f) => f.name).join(", ")}), each with the single command to run and one line on what it will ask for.`
|
|
@@ -5,8 +5,7 @@ import { Text, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { isAdvisoryReport } from "./runtime/advisory-schema.ts";
|
|
6
6
|
import type { WorkflowProgressSnapshot } from "./runtime/progress-types.ts";
|
|
7
7
|
import type { LoadedWorkflow, WorkflowProgressSource, WorkflowRef, WorkflowRunMetadata, WorkflowRunOptions } from "./runtime/types.ts";
|
|
8
|
-
import { WorkflowInspector } from "./runtime/ui/workflow-inspector.ts";
|
|
9
|
-
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./runtime/ui/workflow-viewer-layout.ts";
|
|
8
|
+
import { WorkflowInspector, WORKFLOW_INSPECTOR_OVERLAY_OPTIONS } from "./runtime/ui/workflow-inspector.ts";
|
|
10
9
|
import type { PerfSink } from "./runtime/perf.ts";
|
|
11
10
|
import type { WorkflowUsageSnapshot } from "./runtime/usage.ts";
|
|
12
11
|
import { ADAPTIVE_WORKFLOW_GUIDANCE, registerDynamax } from "./runtime/dynamax.ts";
|
|
@@ -37,8 +36,7 @@ import {
|
|
|
37
36
|
WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
|
|
38
37
|
} from "./runtime/options.ts";
|
|
39
38
|
import { executeWorkflowInvocation, type WorkflowExecution, type WorkflowPerfDetails } from "./runtime/workflow-execution.ts";
|
|
40
|
-
import {
|
|
41
|
-
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./runtime/background-workflow-tool.ts";
|
|
39
|
+
import { WorkflowLifecycle, workflowUnavailableResult } from "./runtime/workflow-lifecycle.ts";
|
|
42
40
|
import { WorkflowRunController } from "./runtime/workflow-run-controller.ts";
|
|
43
41
|
import { completeCurrentArgument, splitArgumentPrefix } from "./runtime/command-completions.ts";
|
|
44
42
|
import { assertSupportedPiVersion } from "./runtime/pi-compat.ts";
|
|
@@ -132,7 +130,6 @@ export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Pro
|
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
const WORKFLOW_OPTION_COMPLETIONS = [
|
|
135
|
-
{ value: "--inspect", description: "Open the live workflow inspector" },
|
|
136
133
|
{ value: "--refresh", description: "Refresh dynamic workflow discovery" },
|
|
137
134
|
{ value: "--perf", description: "Collect workflow performance metrics" },
|
|
138
135
|
{ value: "--result-viewer", description: "Open supported result viewers" },
|
|
@@ -195,9 +192,10 @@ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: A
|
|
|
195
192
|
() => done(undefined),
|
|
196
193
|
undefined,
|
|
197
194
|
source,
|
|
195
|
+
_keybindings,
|
|
198
196
|
);
|
|
199
197
|
},
|
|
200
|
-
|
|
198
|
+
WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
|
|
201
199
|
);
|
|
202
200
|
} finally {
|
|
203
201
|
unsubscribe?.();
|
|
@@ -263,7 +261,7 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
|
|
|
263
261
|
for (let i = 0; i < tokens.length; i++) {
|
|
264
262
|
const token = tokens[i];
|
|
265
263
|
if (token === "--inspect") {
|
|
266
|
-
|
|
264
|
+
optionErrors.push("--inspect was removed; open the running workflow with /workflow");
|
|
267
265
|
continue;
|
|
268
266
|
}
|
|
269
267
|
if (token === "--refresh") {
|
|
@@ -413,7 +411,6 @@ export interface WorkflowToolRequestParams {
|
|
|
413
411
|
readonly name?: string;
|
|
414
412
|
readonly script?: string;
|
|
415
413
|
readonly resumeFromRunId?: string;
|
|
416
|
-
readonly background?: boolean;
|
|
417
414
|
}
|
|
418
415
|
|
|
419
416
|
export type WorkflowToolRequest =
|
|
@@ -445,44 +442,6 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
|
|
|
445
442
|
return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
|
|
446
443
|
}
|
|
447
444
|
|
|
448
|
-
export async function sendWorkflowResult(
|
|
449
|
-
pi: ExtensionAPI,
|
|
450
|
-
ctx: ExtensionContext,
|
|
451
|
-
name: string,
|
|
452
|
-
mod: LoadedWorkflow,
|
|
453
|
-
args: string,
|
|
454
|
-
options: WorkflowRunOptions,
|
|
455
|
-
perfRecorder?: PerfSink,
|
|
456
|
-
reviewSessions: ReviewSessionCoordinator = createReviewSessionCoordinator(pi),
|
|
457
|
-
): Promise<void> {
|
|
458
|
-
await sendResolvedWorkflowResult(
|
|
459
|
-
pi,
|
|
460
|
-
ctx,
|
|
461
|
-
name,
|
|
462
|
-
mod,
|
|
463
|
-
args,
|
|
464
|
-
resolveWorkflowRunOptions(options),
|
|
465
|
-
perfRecorder,
|
|
466
|
-
reviewSessions,
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
async function sendResolvedWorkflowResult(
|
|
471
|
-
pi: ExtensionAPI,
|
|
472
|
-
ctx: ExtensionContext,
|
|
473
|
-
name: string,
|
|
474
|
-
mod: LoadedWorkflow,
|
|
475
|
-
args: string,
|
|
476
|
-
options: ResolvedWorkflowRunOptions,
|
|
477
|
-
perfRecorder: PerfSink | undefined,
|
|
478
|
-
reviewSessions: ReviewSessionCoordinator,
|
|
479
|
-
): Promise<void> {
|
|
480
|
-
const execution = await executeResolvedWorkflow(pi, ctx, name, mod, args, options, perfRecorder);
|
|
481
|
-
reviewSessions.remember(ctx, execution, options);
|
|
482
|
-
sendWorkflowExecution(pi, execution);
|
|
483
|
-
await reviewSessions.present(ctx, execution, options);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
445
|
async function executeResolvedWorkflow(
|
|
487
446
|
pi: ExtensionAPI,
|
|
488
447
|
ctx: ExtensionContext,
|
|
@@ -548,8 +507,8 @@ function createReviewSessionCoordinator(pi: ExtensionAPI): ReviewSessionCoordina
|
|
|
548
507
|
export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts()): void {
|
|
549
508
|
assertSupportedPiVersion(VERSION);
|
|
550
509
|
const reviewSessions = createReviewSessionCoordinator(pi);
|
|
551
|
-
const
|
|
552
|
-
const workflowRuns = new WorkflowRunController(
|
|
510
|
+
const lifecycle = new WorkflowLifecycle(pi);
|
|
511
|
+
const workflowRuns = new WorkflowRunController(lifecycle, {
|
|
553
512
|
async resolveWorkflow(name) {
|
|
554
513
|
const { discoverWorkflows } = await loadDiscovery();
|
|
555
514
|
return (await discoverWorkflows(EXTENSION_DIR)).get(name);
|
|
@@ -560,18 +519,18 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
560
519
|
reviewSessions.remember(ctx, execution, options);
|
|
561
520
|
},
|
|
562
521
|
});
|
|
563
|
-
|
|
522
|
+
lifecycle.onRunSettled((ctx, runId) => workflowRuns.runSettled(ctx, runId));
|
|
564
523
|
registerDynamax(pi, shortcuts, { openInspector: (ctx) => openAvailableWorkflowInspector(pi, ctx) });
|
|
565
524
|
pi.on("session_start", async (_event, ctx) => {
|
|
566
|
-
await
|
|
525
|
+
await lifecycle.sessionStarted(ctx);
|
|
567
526
|
await workflowRuns.sessionStarted(ctx);
|
|
568
527
|
});
|
|
569
528
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
570
|
-
await
|
|
529
|
+
await lifecycle.agentSettled(ctx);
|
|
571
530
|
});
|
|
572
531
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
573
532
|
workflowRuns.sessionShutdown(ctx);
|
|
574
|
-
await
|
|
533
|
+
await lifecycle.sessionShutdown(ctx);
|
|
575
534
|
const key = sessionKey(ctx);
|
|
576
535
|
workflowInspections.get(pi)?.delete(key);
|
|
577
536
|
reviewSessions.dispose(ctx);
|
|
@@ -619,24 +578,36 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
619
578
|
ctx.ui.notify(`Unknown workflow "${direct.name}". Available: ${available}`, "error");
|
|
620
579
|
return;
|
|
621
580
|
}
|
|
622
|
-
|
|
581
|
+
const unavailable = workflowUnavailableResult(ctx.mode);
|
|
582
|
+
if (unavailable) {
|
|
583
|
+
ctx.ui.notify(unavailable.content[0].text, "warning");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const started = await lifecycle.launch({
|
|
587
|
+
ctx, name: direct.name, options: directOptions,
|
|
588
|
+
async execute(runCtx, options) {
|
|
589
|
+
const execution = await executeResolvedWorkflow(pi, runCtx, direct.name, mod, direct.args, options, perfRecorder);
|
|
590
|
+
reviewSessions.remember(ctx, execution, options);
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
ctx.ui.notify(started.content[0].text, started.details.error ? "error" : "info");
|
|
623
594
|
},
|
|
624
595
|
});
|
|
625
596
|
|
|
626
|
-
registerWorkflowTool(pi, reviewSessions,
|
|
597
|
+
registerWorkflowTool(pi, reviewSessions, lifecycle);
|
|
627
598
|
}
|
|
628
599
|
|
|
629
600
|
/** Register the host-facing workflow tool independently from command and lifecycle surfaces. */
|
|
630
601
|
function registerWorkflowTool(
|
|
631
602
|
pi: ExtensionAPI,
|
|
632
603
|
reviewSessions: ReviewSessionCoordinator,
|
|
633
|
-
|
|
604
|
+
lifecycle: WorkflowLifecycle,
|
|
634
605
|
): void {
|
|
635
606
|
pi.registerTool({
|
|
636
607
|
name: "workflow",
|
|
637
608
|
label: "Workflow",
|
|
638
609
|
description:
|
|
639
|
-
"ONLY call workflow when the user opted in with the literal token `dynamax`, explicitly requested a workflow, or invoked a command or skill that requires one.
|
|
610
|
+
"ONLY call workflow when the user opted in with the literal token `dynamax`, explicitly requested a workflow, or invoked a command or skill that requires one. Starts named or inline multi-agent workflows and returns a run ID immediately. Use list, inspect, or stop to manage runs and individual agents.",
|
|
640
611
|
promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
|
|
641
612
|
promptGuidelines: [
|
|
642
613
|
"Use workflow only after a `dynamax` opt-in, an explicit workflow request, or a command or skill instruction.",
|
|
@@ -650,12 +621,16 @@ function registerWorkflowTool(
|
|
|
650
621
|
"If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
|
|
651
622
|
"`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
|
|
652
623
|
ADAPTIVE_WORKFLOW_GUIDANCE,
|
|
653
|
-
"
|
|
654
|
-
"
|
|
624
|
+
"All runs return a durable run ID immediately; completion is delivered later. Use action list/inspect/stop to observe or cancel without launching another workflow.",
|
|
625
|
+
"Every api.agent() call must explicitly supply label, model, and thinkingLevel; no implicit host model or thinking defaults.",
|
|
626
|
+
"Set autoResumeOnUsageLimit: true only when the user wants bounded automatic continuation after a recognized provider usage window.",
|
|
655
627
|
"Set resumeEditedWorkflow: true only with resumeFromRunId when the user explicitly accepts reusing behaviorally identical calls after workflow source edits.",
|
|
656
|
-
"
|
|
628
|
+
"Launch calls must provide exactly one of name or script. Management calls use action, runId, and optionally agentId instead.",
|
|
657
629
|
],
|
|
658
630
|
parameters: Type.Object({
|
|
631
|
+
action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("list"), Type.Literal("inspect"), Type.Literal("stop")], { description: "Defaults to start. Management actions do not launch a workflow." })),
|
|
632
|
+
runId: Type.Optional(Type.String({ minLength: 1, description: "Run to inspect or stop" })),
|
|
633
|
+
agentId: Type.Optional(Type.Integer({ minimum: 1, description: "Inspect or stop only this agent" })),
|
|
659
634
|
name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
|
|
660
635
|
script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
|
|
661
636
|
args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
|
|
@@ -677,7 +652,7 @@ function registerWorkflowTool(
|
|
|
677
652
|
}),
|
|
678
653
|
),
|
|
679
654
|
autoResumeOnUsageLimit: Type.Optional(
|
|
680
|
-
Type.Boolean({ description: "
|
|
655
|
+
Type.Boolean({ description: "Opt into bounded automatic resume after a recognized provider usage limit" }),
|
|
681
656
|
),
|
|
682
657
|
usageLimitMaxAttempts: Type.Optional(
|
|
683
658
|
Type.Integer({
|
|
@@ -705,17 +680,16 @@ function registerWorkflowTool(
|
|
|
705
680
|
resumeEditedWorkflow: Type.Optional(
|
|
706
681
|
Type.Boolean({ description: "With resumeFromRunId, ignore only workflow-source fingerprint changes while retaining all other replay checks" }),
|
|
707
682
|
),
|
|
708
|
-
background: Type.Optional(Type.Boolean({ description: "Return a durable run ID immediately and deliver completion to this conversation later" })),
|
|
709
683
|
}),
|
|
710
684
|
renderCall(args, theme) {
|
|
711
685
|
const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
|
|
712
|
-
|
|
686
|
+
if (args.action && args.action !== "start") return new Text(`▸ ${theme.fg("toolTitle", "workflow")} ${args.action} ${args.runId ?? ""}`, 0, 0);
|
|
713
687
|
if (args.name?.trim()) {
|
|
714
|
-
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${
|
|
688
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${suffix}`, 0, 0);
|
|
715
689
|
}
|
|
716
690
|
const preview = compactInlinePreview(args.script);
|
|
717
691
|
const previewSuffix = preview ? ` ${theme.fg("dim", preview)}` : "";
|
|
718
|
-
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${
|
|
692
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${suffix}${previewSuffix}`, 0, 0);
|
|
719
693
|
},
|
|
720
694
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
721
695
|
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
@@ -731,6 +705,13 @@ function registerWorkflowTool(
|
|
|
731
705
|
return new Text(theme.fg("muted", text), 0, 0);
|
|
732
706
|
},
|
|
733
707
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
708
|
+
if (params.action && params.action !== "start") {
|
|
709
|
+
const { manageWorkflow } = await import("./runtime/workflow-management.ts");
|
|
710
|
+
return await manageWorkflow({ ...params, action: params.action }, ctx, lifecycle, workflowInspectionState(pi, ctx).active);
|
|
711
|
+
}
|
|
712
|
+
if (params.runId !== undefined || params.agentId !== undefined) {
|
|
713
|
+
return { content: [{ type: "text", text: "runId and agentId require inspect or stop." }], details: { error: "invalid_workflow_invocation" } };
|
|
714
|
+
}
|
|
734
715
|
const request = normalizeWorkflowToolRequest(params);
|
|
735
716
|
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
736
717
|
const resumeFromRunId = params.resumeFromRunId?.trim();
|
|
@@ -746,13 +727,10 @@ function registerWorkflowTool(
|
|
|
746
727
|
details: { error: "invalid_edited_workflow_resume" },
|
|
747
728
|
};
|
|
748
729
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (unavailable) return unavailable;
|
|
752
|
-
}
|
|
730
|
+
const unavailable = workflowUnavailableResult(ctx.mode);
|
|
731
|
+
if (unavailable) return unavailable;
|
|
753
732
|
|
|
754
733
|
const runOptions = resolveWorkflowRunOptions({
|
|
755
|
-
inspect: ctx.hasUI && ctx.mode === "tui",
|
|
756
734
|
concurrency: params.concurrency,
|
|
757
735
|
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
758
736
|
maxAgents: params.maxAgents,
|
|
@@ -796,41 +774,15 @@ function registerWorkflowTool(
|
|
|
796
774
|
}
|
|
797
775
|
|
|
798
776
|
const resultArgs = params.args ?? "";
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
options
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
backgroundCtx,
|
|
809
|
-
resultName,
|
|
810
|
-
mod,
|
|
811
|
-
resultArgs,
|
|
812
|
-
backgroundOptions,
|
|
813
|
-
perfRecorder,
|
|
814
|
-
);
|
|
815
|
-
reviewSessions.remember(ctx, execution, backgroundOptions);
|
|
816
|
-
},
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
const execution = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, runOptions, perfRecorder);
|
|
820
|
-
reviewSessions.remember(ctx, execution, runOptions);
|
|
821
|
-
return {
|
|
822
|
-
content: [{
|
|
823
|
-
type: "text",
|
|
824
|
-
text: formatMessageContent(
|
|
825
|
-
resultName,
|
|
826
|
-
execution.envelope.result,
|
|
827
|
-
execution.envelope.usage,
|
|
828
|
-
execution.envelope.perf,
|
|
829
|
-
execution.metadata,
|
|
830
|
-
),
|
|
831
|
-
}],
|
|
832
|
-
details: execution.envelope,
|
|
833
|
-
};
|
|
777
|
+
return await lifecycle.launch({
|
|
778
|
+
ctx,
|
|
779
|
+
name: resultName,
|
|
780
|
+
options: runOptions,
|
|
781
|
+
async execute(runCtx, options) {
|
|
782
|
+
const execution = await executeResolvedWorkflow(pi, runCtx, resultName, mod, resultArgs, options, perfRecorder);
|
|
783
|
+
reviewSessions.remember(ctx, execution, options);
|
|
784
|
+
},
|
|
785
|
+
});
|
|
834
786
|
},
|
|
835
787
|
});
|
|
836
788
|
}
|
|
@@ -41,7 +41,7 @@ export function needsChallenge(finding: AdvisoryVerified): boolean {
|
|
|
41
41
|
|
|
42
42
|
/** A bounded recipe using existing agent/parallel calls, not a new runtime primitive. */
|
|
43
43
|
export async function challengeFindings<T extends AdvisoryVerified>(
|
|
44
|
-
api: Pick<WorkflowApi, "agent" | "parallel">,
|
|
44
|
+
api: Pick<WorkflowApi, "agent" | "modelProfile" | "parallel">,
|
|
45
45
|
findings: T[],
|
|
46
46
|
context: string,
|
|
47
47
|
options: AdvisoryChallengeOptions,
|
|
@@ -56,12 +56,12 @@ export async function challengeFindings<T extends AdvisoryVerified>(
|
|
|
56
56
|
replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed" } });
|
|
57
57
|
const challenge = await api.agent(
|
|
58
58
|
`Assume this finding is a false positive. Try to DISPROVE it. Find the strongest concrete counterexample or alternative root cause. Inspect callers, invariants, tests and control flow. For a repair, seek an input, race or error path that still fails. State the smallest experiment distinguishing explanations. Do not edit files or claim tests you did not run. No counterexample found is not proof.\n\nExact review context:\n${context}\n\nCandidate and verifier evidence:\n${JSON.stringify(finding)}`,
|
|
59
|
-
{ label: `challenge:${finding.candidateId}`, phase: "Challenge",
|
|
59
|
+
{ label: `challenge:${finding.candidateId}`, phase: "Challenge", ...api.modelProfile("medium"), tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, schema: ChallengeSchema },
|
|
60
60
|
);
|
|
61
61
|
replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed", challenge } });
|
|
62
62
|
const adjudication = await api.agent(
|
|
63
63
|
`Adjudicate the original finding, independent verifier evidence and falsification attempt below. Preserve unresolved conflict; do not force consensus. A missing counterexample alone cannot upgrade a plausible claim. Cite concrete evidence and observed test results; never invent experiments.\nContext:\n${context}\nOriginal and verifier:\n${JSON.stringify(finding)}\nChallenger:\n${JSON.stringify(challenge)}`,
|
|
64
|
-
{ label: `adjudicate:${finding.candidateId}`, phase: "Challenge",
|
|
64
|
+
{ label: `adjudicate:${finding.candidateId}`, phase: "Challenge", ...api.modelProfile("medium"), tools: [], schema: AdjudicationSchema },
|
|
65
65
|
);
|
|
66
66
|
replacements.set(finding.candidateId, {
|
|
67
67
|
...finding,
|