Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/sf-agent-start-clarity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@stoneforge/smithy": patch
---

fix: clearer error from `sf agent start` when headless invocation has no input source

`sf agent start <id>` for a worker/steward in headless mode now refuses
upfront when invoked without --prompt or --resume, with an explanation
and the three fixes (--prompt, --resume, --mode interactive). Replaces
the cryptic "Session exited before init" error users previously saw.
35 changes: 35 additions & 0 deletions packages/smithy/src/cli/commands/agent.bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,39 @@ describe('agent disable / enable behavioural', () => {
rmSync(tmpRoot, { recursive: true, force: true });
}
});

test('start refuses headless invocation without --prompt or --resume', async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), 'sf-start-validation-test-'));
const cwdBefore = process.cwd();
try {
mkdirSync(join(tmpRoot, '.stoneforge'), { recursive: true });
const dbPath = join(tmpRoot, '.stoneforge', 'stoneforge.db');
const tmpBackend = createStorage({ path: dbPath, create: true });
initializeSchema(tmpBackend);
const tmpApi = createOrchestratorAPI(tmpBackend);
const registered = await tmpApi.registerWorker({
name: 'naked-worker',
workerMode: 'ephemeral',
createdBy: CREATOR,
});

process.chdir(tmpRoot);
try {
const result = await agentStartCommand.handler!(
[registered.id as unknown as string],
{ db: dbPath } as never
);
expect(result.exitCode).not.toBe(0);
const errMsg = String(result.error ?? '');
expect(errMsg).toContain('headless');
expect(errMsg).toContain('--prompt');
expect(errMsg).toContain('--resume');
expect(errMsg).toContain('--mode interactive');
} finally {
process.chdir(cwdBefore);
}
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
});
26 changes: 26 additions & 0 deletions packages/smithy/src/cli/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,30 @@ async function agentStartHandler(
spawnMode = options.mode as 'headless' | 'interactive';
}

// Resolve effective spawn mode using the same rule as
// SpawnerServiceImpl.determineSpawnMode: director → interactive,
// worker/steward → headless, unless explicitly overridden.
const effectiveMode: 'headless' | 'interactive' =
spawnMode ?? (agentRole === 'director' ? 'interactive' : 'headless');

if (effectiveMode === 'headless' && !options.prompt && !options.resume) {
return failure(
[
`Cannot start a headless ${agentRole} without an initial prompt.`,
`Headless agents read input via stdin; without --prompt or --resume the process exits immediately.`,
``,
`Provide one of:`,
` --prompt "..." Initial instructions for the agent`,
` --resume <session-id> Resume a previous session`,
` --mode interactive Start as an interactive (PTY) session`,
``,
`Note: in dispatch-driven workflows, the daemon spawns workers automatically when tasks are ready.`,
`Manual restart of ephemeral workers is usually unnecessary.`,
].join('\n'),
ExitCode.VALIDATION
);
}

// Spawn the agent
const result = await spawner.spawn(id as EntityId, agentRole, {
initialPrompt: options.prompt,
Expand Down Expand Up @@ -1041,6 +1065,8 @@ Options:
--provider <name> Override agent provider for this session
--model <model> Override model for this session

For headless workers/stewards (default), one of --prompt, --resume, or --mode interactive is required.

Examples:
sf agent start el-abc123
sf agent start el-abc123 --mode interactive
Expand Down