Skip to content

Commit 4088ef7

Browse files
committed
feat(workflow): show the plan before a Dynamic Workflow runs
Manual mode approved every DynamicWorkflow call outright. That policy sat below auto- and yolo-approve, so it only ever fired in manual mode — the one mode whose purpose is to ask was the one mode that never saw what it was agreeing to. Removing it lets the call reach the ask, and the approval now carries the fan-out: subagent count, task list, prompt template, worker model, and the summed size of the prompts about to be sent. The token figure is the real summed prompt estimate rather than a projected total cost. A guessed multiplier in an approval dialog is worse than no number, because the operator would be approving against a figure that could be an order of magnitude out. "Approve for this session" is keyed on the workflow description, so agreeing to one small review no longer pre-approves a later 128-agent fan-out. The rule ships with its matcher: an arg-bearing approval rule with no `matchesRule` never matches, which would record the grant and then ignore it every time. Also adds `/workflow save <name>`, which writes the last run out as a skill bundle under `.pythinker-code/skills/` so a fan-out that worked can be re-run by name. Names are validated rather than merely lowercased — `normalizeSkillName` is not a sanitizer, and feeding it straight to `path.join` let `../../..` place the file anywhere on disk.
1 parent a0fc153 commit 4088ef7

22 files changed

Lines changed: 892 additions & 55 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@pythoughts/pythinker-code-sdk': minor
3+
'@pythoughts/pythinker-code': minor
4+
---
5+
6+
Show the plan before a Dynamic Workflow runs, and let a good one be saved as a command
7+
8+
Manual mode used to approve every `DynamicWorkflow` call outright. That approval
9+
only ever fired in manual mode — auto and yolo approve earlier in the chain — so
10+
the one mode whose purpose is to ask was the one mode that never saw what it was
11+
agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the approval
12+
carries the fan-out: how many subagents, the task list, the prompt template, the
13+
worker model, and the summed size of the prompts about to be sent. "Approve for
14+
this session" is keyed to that workflow's description rather than granting every
15+
future `DynamicWorkflow` call.
16+
17+
`/workflow save <name>` writes the last run back out as a skill under
18+
`.pythinker-code/skills/`, so a fan-out that worked can be re-run by name.

apps/pythinker-code/src/tui/commands/dynamic-workflow.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import type { PermissionMode } from '@pythoughts/pythinker-code-sdk';
1+
import { mkdir, writeFile } from 'node:fs/promises';
22

3+
import {
4+
renderSavedWorkflowSkill,
5+
savedWorkflowSkillDir,
6+
savedWorkflowSkillName,
7+
type PermissionMode,
8+
} from '@pythoughts/pythinker-code-sdk';
9+
import { join } from 'pathe';
10+
11+
import { getDataDir } from '#/utils/paths';
312
import {
413
DynamicWorkflowStartPermissionPromptComponent,
514
type DynamicWorkflowStartPermissionChoice,
@@ -25,6 +34,7 @@ export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args:
2534

2635
const prompt = args.trim();
2736
if (handleModelSubcommand(host, prompt)) return;
37+
if (await handleSaveSubcommand(host, prompt)) return;
2838

2939
const mode = dynamicWorkflowModeSubcommand(prompt);
3040
if (mode !== undefined) {
@@ -114,6 +124,75 @@ function withWorkerModelInstruction(prompt: string, model: string | undefined):
114124
: `${prompt}\n\nUse model "${model}" for the DynamicWorkflow subagents in this task.`;
115125
}
116126

127+
/**
128+
* `/workflow save <name>` writes the last run back out as a skill, so a fan-out
129+
* that worked can be re-run by name instead of re-described.
130+
*
131+
* Returns true when the input was a `save` subcommand and has been handled.
132+
*/
133+
async function handleSaveSubcommand(host: SlashCommandHost, input: string): Promise<boolean> {
134+
const match = /^save(?:\s+(.*))?$/iu.exec(input);
135+
if (match === null) return false;
136+
137+
const name = match[1]?.trim() ?? '';
138+
if (name.length === 0) {
139+
host.showError('Usage: /workflow save <name>');
140+
return true;
141+
}
142+
143+
const args = host.state.lastDynamicWorkflowArgs;
144+
if (args === undefined) {
145+
host.showError('No Dynamic Workflow has run in this session yet.');
146+
return true;
147+
}
148+
149+
const description = stringArg(args, 'description');
150+
if (description === undefined) {
151+
host.showError('The last Dynamic Workflow has no description to save.');
152+
return true;
153+
}
154+
155+
try {
156+
const dir = savedWorkflowSkillDir({
157+
scope: 'project',
158+
name,
159+
projectRoot: host.state.appState.workDir,
160+
brandHomeDir: getDataDir(),
161+
});
162+
await mkdir(dir, { recursive: true });
163+
await writeFile(
164+
join(dir, 'SKILL.md'),
165+
renderSavedWorkflowSkill({
166+
name: savedWorkflowSkillName(name),
167+
description,
168+
subagentType: stringArg(args, 'subagent_type'),
169+
promptTemplate: stringArg(args, 'prompt_template'),
170+
model: stringArg(args, 'model'),
171+
effort: stringArg(args, 'effort'),
172+
outputSchema: recordArg(args, 'output_schema'),
173+
}),
174+
'utf8',
175+
);
176+
host.refreshSlashCommandAutocomplete();
177+
host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`);
178+
} catch (error) {
179+
host.showError(`Failed to save workflow: ${formatErrorMessage(error)}`);
180+
}
181+
return true;
182+
}
183+
184+
function stringArg(args: Record<string, unknown>, key: string): string | undefined {
185+
const value = args[key];
186+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
187+
}
188+
189+
function recordArg(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
190+
const value = args[key];
191+
return typeof value === 'object' && value !== null && !Array.isArray(value)
192+
? (value as Record<string, unknown>)
193+
: undefined;
194+
}
195+
117196
/** Returns true when the input was a `model` subcommand and has been handled. */
118197
function handleModelSubcommand(host: SlashCommandHost, input: string): boolean {
119198
const match = /^model(?:\s+(.*))?$/iu.exec(input);

apps/pythinker-code/src/tui/commands/registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const DYNAMIC_WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
2121
{ value: 'on', description: 'Turn Dynamic Workflow mode on' },
2222
{ value: 'off', description: 'Turn Dynamic Workflow mode off' },
2323
{ value: 'model', description: 'Set the model Dynamic Workflow subagents run on' },
24+
{ value: 'save', description: 'Save the last Dynamic Workflow as a reusable command' },
2425
];
2526

2627
const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [

apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
DisplayBlock,
3333
FileContentDisplayBlock,
3434
PendingApproval,
35+
WorkflowPlanDisplayBlock,
3536
} from '#/tui/reverse-rpc/types';
3637
import { printableChar } from '#/tui/utils/printable-key';
3738

@@ -167,6 +168,8 @@ function renderDisplayBlock(
167168
}
168169
return lines;
169170
}
171+
case 'workflow_plan':
172+
return renderWorkflowPlanDisplayBlock(block, s);
170173
case 'brief':
171174
return block.text
172175
? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : ''))
@@ -182,6 +185,41 @@ function renderDisplayBlock(
182185
}
183186
}
184187

188+
/**
189+
* A workflow can carry up to 128 items. Listing all of them would push the
190+
* buttons off the screen, so the panel shows enough to judge the shape of the
191+
* fan-out and says how many it held back.
192+
*/
193+
const MAX_PREVIEW_ITEMS = 10;
194+
195+
function renderWorkflowPlanDisplayBlock(
196+
block: WorkflowPlanDisplayBlock,
197+
s: BlockStyles,
198+
): string[] {
199+
const plural = block.agent_count === 1 ? 'subagent' : 'subagents';
200+
const summary = [
201+
`${String(block.agent_count)} ${plural}`,
202+
`~${String(block.prompt_tokens)} prompt tokens`,
203+
];
204+
if (block.model !== undefined && block.model.length > 0) {
205+
summary.push(`model: ${block.model}`);
206+
}
207+
const lines = [s.strong(summary.join(' '))];
208+
209+
if (block.prompt_template !== undefined && block.prompt_template.length > 0) {
210+
lines.push(`${s.accent('prompt')} ${s.dim(truncateOneLine(block.prompt_template, 200))}`);
211+
}
212+
213+
for (const [index, item] of block.items.slice(0, MAX_PREVIEW_ITEMS).entries()) {
214+
lines.push(s.dim(`${String(index + 1).padStart(3)}. ${truncateOneLine(item, 120)}`));
215+
}
216+
const hidden = block.items.length - MAX_PREVIEW_ITEMS;
217+
if (hidden > 0) {
218+
lines.push(s.dim(` +${String(hidden)} more`));
219+
}
220+
return lines;
221+
}
222+
185223
function normalizeApprovalText(text: string): string {
186224
return text.replaceAll('\r\n', '\n').trim();
187225
}
@@ -209,6 +247,8 @@ function headerFor(toolName: string): string {
209247
return 'Stop this task?';
210248
case 'ExitPlanMode':
211249
return 'Ready to build with this plan?';
250+
case 'DynamicWorkflow':
251+
return 'Run this Dynamic Workflow?';
212252
default:
213253
return `Approve ${toolName}?`;
214254
}

apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,8 @@ export class SubAgentEventHandler {
633633
args: Record<string, unknown>,
634634
options: { readonly streamingArguments?: string } = {},
635635
): DynamicWorkflowMissionControlComponent {
636+
// Kept so `/workflow save` can name a run the user just watched succeed.
637+
this.host.state.lastDynamicWorkflowArgs = args;
636638
const existing = this.dynamicWorkflowMissionControls.get(toolCallId);
637639
if (existing !== undefined) {
638640
existing.updateArgs(args, options);

apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,15 +300,27 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
300300
scope: display.scope,
301301
},
302302
];
303-
case 'agent_call':
304-
return [
303+
case 'agent_call': {
304+
const blocks: DisplayBlock[] = [
305305
{
306306
type: 'invocation',
307307
kind: 'agent',
308308
name: display.agent_name ?? '',
309309
description: display.prompt,
310310
},
311311
];
312+
if (display.workflow !== undefined) {
313+
blocks.push({
314+
type: 'workflow_plan',
315+
agent_count: display.workflow.agent_count,
316+
items: [...display.workflow.items],
317+
prompt_tokens: display.workflow.prompt_tokens,
318+
prompt_template: display.workflow.prompt_template,
319+
model: display.workflow.model,
320+
});
321+
}
322+
return blocks;
323+
}
312324
case 'skill_call':
313325
return [
314326
{

apps/pythinker-code/src/tui/reverse-rpc/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ export interface InvocationDisplayBlock {
6868
description?: string | undefined;
6969
}
7070

71+
/**
72+
* The fan-out a Dynamic Workflow is about to launch. Shown at approval time so
73+
* the decision is made against the actual task list rather than a count.
74+
*/
75+
export interface WorkflowPlanDisplayBlock {
76+
type: 'workflow_plan';
77+
agent_count: number;
78+
items: string[];
79+
prompt_tokens: number;
80+
prompt_template?: string;
81+
model?: string;
82+
}
83+
7184
export interface TodoDisplayItem {
7285
title: string;
7386
status: 'pending' | 'in_progress' | 'done';
@@ -95,6 +108,7 @@ export type DisplayBlock =
95108
| UrlFetchDisplayBlock
96109
| SearchDisplayBlock
97110
| InvocationDisplayBlock
111+
| WorkflowPlanDisplayBlock
98112
| TodoDisplayBlock
99113
| BackgroundTaskDisplayBlock;
100114

apps/pythinker-code/src/tui/tui-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export interface TUIState {
6363
externalEditorRunning: boolean;
6464
queuedMessages: QueuedMessage[];
6565
dynamicWorkflowModeEntry: 'manual' | 'task' | undefined;
66+
/**
67+
* Arguments of the most recent DynamicWorkflow tool call, so `/workflow save`
68+
* can turn a run that just worked into a reusable command. Overwritten as the
69+
* call streams in; the last write is the complete one.
70+
*/
71+
lastDynamicWorkflowArgs: Record<string, unknown> | undefined;
6672
}
6773

6874
export function createTUIState(options: PythinkerTUIOptions): TUIState {
@@ -143,5 +149,6 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState {
143149
externalEditorRunning: false,
144150
queuedMessages: [],
145151
dynamicWorkflowModeEntry: undefined,
152+
lastDynamicWorkflowArgs: undefined,
146153
};
147154
}

apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { promises as fs } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
4+
import { join } from 'pathe';
15
import { describe, expect, it, vi } from 'vitest';
26

37
import { handleDynamicWorkflowCommand } from '#/tui/commands/index';
@@ -24,6 +28,8 @@ function makeHost(
2428
permissionMode?: 'manual' | 'auto' | 'yolo';
2529
dynamicWorkflowMode?: boolean;
2630
availableModels?: Record<string, unknown>;
31+
workDir?: string;
32+
lastDynamicWorkflowArgs?: Record<string, unknown>;
2733
} = {},
2834
) {
2935
const session = {
@@ -40,10 +46,12 @@ function makeHost(
4046
availableModels: overrides.availableModels ?? {
4147
'deepseek-v4': { provider: 'deepseek', model: 'deepseek-v4' },
4248
},
49+
workDir: overrides.workDir ?? '/workspace',
4350
},
4451
theme: currentTheme,
4552
transcriptContainer: { addChild: vi.fn() },
4653
ui: { requestRender: vi.fn() },
54+
lastDynamicWorkflowArgs: overrides.lastDynamicWorkflowArgs,
4755
},
4856
session: hasSession ? session : undefined,
4957
requireSession: () => session,
@@ -54,6 +62,7 @@ function makeHost(
5462
restoreEditor: vi.fn(),
5563
restoreInputText: vi.fn(),
5664
sendNormalUserInput: vi.fn(),
65+
refreshSlashCommandAutocomplete: vi.fn(),
5766
} as unknown as SlashCommandHost;
5867
return { host, session };
5968
}
@@ -410,3 +419,76 @@ describe('handleDynamicWorkflowCommand', () => {
410419
);
411420
});
412421
});
422+
423+
describe('/workflow save', () => {
424+
it('writes the last run as a skill and refreshes the command list', async () => {
425+
const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-'));
426+
try {
427+
const { host } = makeHost({
428+
permissionMode: 'auto',
429+
workDir,
430+
lastDynamicWorkflowArgs: {
431+
description: 'Audit routes for missing auth',
432+
subagent_type: 'reviewer',
433+
prompt_template: 'Audit {{item}}',
434+
model: 'deepseek-v4',
435+
items: ['a.ts', 'b.ts'],
436+
},
437+
});
438+
439+
await handleDynamicWorkflowCommand(host, 'save Audit Routes');
440+
441+
const saved = await fs.readFile(
442+
join(workDir, '.pythinker-code', 'skills', 'audit-routes', 'SKILL.md'),
443+
'utf8',
444+
);
445+
expect(saved).toContain('name: "audit-routes"');
446+
expect(saved).toContain('description: "Audit routes for missing auth"');
447+
expect(saved).toContain('subagent-type: "reviewer"');
448+
expect(saved).toContain('Audit {{item}}');
449+
expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled();
450+
expect(host.showError).not.toHaveBeenCalled();
451+
} finally {
452+
await fs.rm(workDir, { recursive: true, force: true });
453+
}
454+
});
455+
456+
it('refuses a name that would escape the project skills directory', async () => {
457+
const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-'));
458+
try {
459+
const { host } = makeHost({
460+
permissionMode: 'auto',
461+
workDir,
462+
lastDynamicWorkflowArgs: { description: 'Audit routes' },
463+
});
464+
465+
await handleDynamicWorkflowCommand(host, 'save ../../../../tmp/pwned');
466+
467+
expect(host.showError).toHaveBeenCalledWith(
468+
expect.stringContaining('not a valid skill name'),
469+
);
470+
expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled();
471+
await expect(fs.stat(join(workDir, '.pythinker-code'))).rejects.toThrow(/ENOENT/);
472+
} finally {
473+
await fs.rm(workDir, { recursive: true, force: true });
474+
}
475+
});
476+
477+
it('explains itself when no workflow has run yet', async () => {
478+
const { host } = makeHost({ permissionMode: 'auto' });
479+
480+
await handleDynamicWorkflowCommand(host, 'save nightly-audit');
481+
482+
expect(host.showError).toHaveBeenCalledWith(
483+
'No Dynamic Workflow has run in this session yet.',
484+
);
485+
});
486+
487+
it('asks for a name when given none', async () => {
488+
const { host } = makeHost({ permissionMode: 'auto' });
489+
490+
await handleDynamicWorkflowCommand(host, 'save');
491+
492+
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name>');
493+
});
494+
});

0 commit comments

Comments
 (0)