tasks: run straight from the menu when there is nothing to ask

a task that takes real options — which audio tracks to keep, what format
to convert to — has to ask, and the runner modal is for that. a task that
is one command over the clicked file has nothing to ask, and putting a
form in front of it with one pre-filled field and a Run button is a click
that decides nothing.

derived, not declared. the rule is whether anything is left over once the
file browser has filled in what it knows, which is two things and only
two: file_path by name, and any input declaring autofill entry_name /
entry_path. a default does NOT count — a default is a suggestion and the
user has to see it to change it. agentic, pipeline, inline true and
inline 'ask' always take the modal; the last because the choice is itself
the question, and the one before it because the modal is where an
ephemeral run's output goes.

and the reason the transcribe task's one field came up EMPTY: inputs is a
list in TASK.md and a map everywhere else. the app's own task-creation
guide teaches the list form, so that is what gets written; every consumer
reads the map. a list arrived at the form as Object.entries(array), key
"0" — so the input rendered nameless, was never matched against what the
browser auto-fills, and would have run with nothing in it. normalised in
parseTaskMd, which fixes the form, the INPUT_<NAME> env vars and the
positional args together. the map form still passes through.

GET /tasks now carries inline and inputs so the menu can decide without a
second request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 04:40:07 +00:00
co-authored by Claude Opus 5
parent 6d673a1321
commit 121ddb9964
12 changed files with 223 additions and 3 deletions
+45
View File
@@ -98,3 +98,48 @@ describe('tasks across sources', () => {
}
});
});
/**
* `inputs` is a LIST in TASK.md and a map everywhere else.
*
* The app's own task-creation guide teaches the list form, so agents write it; every consumer reads the
* map. A list reached the runner as `Object.entries(array)` — key "0" — so the input rendered nameless,
* never matched what the file browser auto-fills, and ran empty. That is exactly what the transcribe
* plugin's task did the first time it was clicked.
*/
describe('input normalisation', () => {
const withInputs = (yaml: string) => `---\nname: T\nmode: script\n${yaml}---\n\nbody\n`;
test('the list form becomes a map keyed by name', async () => {
const owner = seed({
't/TASK.md': withInputs('inputs:\n - name: file_path\n description: the file\n required: true\n'),
});
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toEqual({ file_path: { description: 'the file', required: true } });
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
test('the map form passes through untouched', async () => {
const owner = seed({ 't/TASK.md': withInputs('inputs:\n file_path:\n description: the file\n') });
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toEqual({ file_path: { description: 'the file' } });
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
// No name means no INPUT_<NAME> env var and no form key — there is nothing to call it.
test('an entry with no name is dropped', async () => {
const owner = seed({ 't/TASK.md': withInputs('inputs:\n - description: nameless\n') });
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
+32 -1
View File
@@ -66,6 +66,9 @@ export type TaskSummary = {
version: number;
trigger: unknown;
plugin: string | null;
/** Normalised to a map keyed by input name. Carried in the list so a caller can tell, without a second
* request, whether running this task needs anything from the user. */
inputs: unknown;
};
// Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1.
@@ -104,6 +107,32 @@ const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md');
const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null);
/**
* `inputs` in TASK.md is a LIST of `{ name, … }`. Everything downstream wants a map keyed by name.
*
* Both shapes are in the wild because the app's own task-creation guide teaches the list form while
* every consumer — the runner modal, the agent runner, `execute-script`'s `INPUT_<NAME>` env vars —
* reads the map. A list reached the form as `Object.entries(array)`, so the key was "0": the input
* rendered with no name, was never matched against what the file browser auto-fills, and ran with an
* empty value. Normalising here fixes every consumer at once, and the map form still passes through.
*/
function normalizeInputs(raw: unknown): Record<string, Record<string, unknown>> | null {
if (!raw) return null;
if (Array.isArray(raw)) {
const map: Record<string, Record<string, unknown>> = {};
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const { name, ...rest } = entry as Record<string, unknown>;
if (typeof name !== 'string' || !name) continue; // no name, no way to pass it to the script
map[name] = rest;
}
return Object.keys(map).length ? map : null;
}
return typeof raw === 'object' ? (raw as Record<string, Record<string, unknown>>) : null;
}
export const slugify = (name: string) =>
name
.trim()
@@ -141,7 +170,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
tags: asArray(parsed.tags),
tools: asArray(parsed.tools),
skills: asArray(parsed.skills),
inputs: parsed.inputs ?? null,
inputs: normalizeInputs(parsed.inputs),
outputs: parsed.outputs ?? null,
dependencies: parsed.dependencies ?? null,
config: parsed.config ?? null,
@@ -224,6 +253,7 @@ export async function listTasks(sources?: CapabilitySource[]): Promise<TaskSumma
version: fm.version,
trigger: fm.trigger ?? [],
plugin: source.plugin,
inputs: fm.inputs,
});
}
}
@@ -282,6 +312,7 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
version: 1,
trigger: [],
plugin: null,
inputs: null,
filePath: taskFile(dirName),
};
}
+2
View File
@@ -16,6 +16,8 @@ tasksRouter.get('/', async (ctx) => {
triggers: (row.trigger as TriggerConfig[]) ?? [],
mode: row.mode ?? 'agentic',
plugin: row.plugin,
inline: row.inline,
inputs: row.inputs,
}));
return ctx.json(tasks);