Skip to content

v0.12.0: dashboard_token config silently ignored, and SkillSynthesizer burns LLM calls in daemon mode #174

Description

@dmmdea

Two related observations from a v0.12.0 deployment running as a systemd --user service. Both stem from upstream-vs-config wiring that's almost-but-not-quite hooked up. Filing as a single issue because they share a common shape (post-PR-#154 surface that doesn't account for the daemon path); happy to split if you'd prefer.

1) [steering].dashboard_token is dead config in v0.12.0

Symptom. Setting dashboard_token = "..." under [steering] in ~/.zora/config.toml and restarting zora-agent does not enable dashboard auth. Journal still logs:

"Dashboard API authentication disabled — no dashboardToken configured"

Cause. dist/cli/daemon.js constructs new DashboardServer({...}) without passing dashboardToken:

// dist/cli/daemon.js:206
const dashboard = new DashboardServer({
    providers,
    sessionManager: orchestrator.sessionManager,
    steeringManager: orchestrator.steeringManager,
    authMonitor: orchestrator.authMonitor,
    costTracker: orchestrator.getTLCICostTracker?.(),
    policy,
    submitTask: async (prompt) => { ... },
    port: config.steering.dashboard_port ?? 8070,
    host: process.env.ZORA_BIND_HOST,
    projectConfig: config.project,
    agentName: config.agent.name,
});

dist/dashboard/server.js:38 only reads:

this._authToken = options.dashboardToken ?? process.env['ZORA_DASHBOARD_TOKEN'];

So a config-only setting silently disables auth (no warning that the configured value was ignored).

Workaround. Set ZORA_DASHBOARD_TOKEN=... in ~/.zora/.env (or any source loaded by your systemd unit's EnvironmentFile=). Verify via journal:

"Dashboard API authentication enabled"

Suggested fix. One line in dist/cli/daemon.js:

   const dashboard = new DashboardServer({
       providers,
+      dashboardToken: config.steering?.dashboard_token,
       ...

(If both env-var and config are set, current precedence in server.js makes dashboardToken option win, which is the right behavior.) Optionally also log a warning when neither is set after the daemon explicitly loads a config that includes a [steering] section, so the failure mode is visible.

2) SkillSynthesizer (PR #154) burns an LLM call per qualifying daemon task

Symptom. In daemon/service mode, every task crossing toolCalls >= 8 OR turns >= 8 triggers a synthesizer LLM call whose result is then silently discarded. Net: tokens spent for zero output.

Cause. Order of operations in dist/skills/SkillSynthesizer.js#maybeGenerateSkill:

const existing = await this.findExistingSkill(...);
if (existing) return;
let content;
try { content = await this.synthesize(session); }   // <-- LLM call here
catch (err) { ... return; }
...
const confirmed = await this._confirmWithUser(name, content);  // HITL gate
if (!confirmed) return;

_confirmWithUser correctly fail-closes when !process.stdin.isTTY:

if (!process.stdin.isTTY) {
    // Daemon/non-interactive context — fail closed to preserve HITL guarantee.
    return false;
}

There's already an explicit TODO: wire an out-of-band confirmer (e.g. via ApprovalQueue) for daemon runs. But until that lands, every qualifying daemon task pays the full synthesis cost for a result that can never be saved.

There's also no env-var or config knob to disable autonomous skill generation; the orchestrator unconditionally instantiates SkillSynthesizer and calls maybeGenerateSkill after every task (dist/orchestrator/orchestrator.js:132,699). The threshold is hardcoded in SKILL_THRESHOLD = { toolCalls: 8, turns: 8 }.

Suggested mitigations (any one is sufficient, in increasing order of effort):

  1. Cheapest: move the TTY check up in maybeGenerateSkill so it short-circuits before synthesize() whenever skipConfirmation is false AND !process.stdin.isTTY. Daemon paths skip the LLM call entirely; CLI ask paths still work.
  2. Add a [skills].autonomous_synthesis_enabled = false config flag (default false for daemon, true for CLI) to make the feature opt-in until the ApprovalQueue lands.
  3. Land the ApprovalQueue integration referenced in the source TODO so daemon mode can also save skills.

End-to-end verification

For #2, I exercised the synthesizer in isolation against a temp baseDir with a stub LLMProvider. The threshold check, dup scan (word-overlap), atomic-write (tmpfile + rename), and <skillsDir>/skills.lock.json SHA-256 manifest update all work correctly. The feature itself is solid; it's just the daemon path that's lossy.

Environment

  • zora-agent v0.12.0 (built from git, npm registry still on 0.11.0 per UPSTREAM.md)
  • WSL2 Ubuntu, Node v24.14.1
  • Running as systemctl --user service with EnvironmentFile=~/.zora/.env

Minor doc nit

SkillSynthesizer.d.ts header comment says the synthesizer "updates the skills.lock.json integrity manifest" without specifying the path. Actual location is <skillsDir>/skills.lock.json, not <baseDir>/skills.lock.json. A one-line addition to the comment would prevent that misread.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions