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):
- 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.
- 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.
- 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.
Two related observations from a v0.12.0 deployment running as a
systemd --userservice. 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_tokenis dead config in v0.12.0Symptom. Setting
dashboard_token = "..."under[steering]in~/.zora/config.tomland restartingzora-agentdoes not enable dashboard auth. Journal still logs:Cause.
dist/cli/daemon.jsconstructsnew DashboardServer({...})without passingdashboardToken:dist/dashboard/server.js:38only reads: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'sEnvironmentFile=). Verify via journal: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
dashboardTokenoption 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 taskSymptom. In daemon/service mode, every task crossing
toolCalls >= 8 OR turns >= 8triggers 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:_confirmWithUsercorrectly fail-closes when!process.stdin.isTTY: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
SkillSynthesizerand callsmaybeGenerateSkillafter every task (dist/orchestrator/orchestrator.js:132,699). The threshold is hardcoded inSKILL_THRESHOLD = { toolCalls: 8, turns: 8 }.Suggested mitigations (any one is sufficient, in increasing order of effort):
maybeGenerateSkillso it short-circuits beforesynthesize()wheneverskipConfirmationis false AND!process.stdin.isTTY. Daemon paths skip the LLM call entirely; CLIaskpaths still work.[skills].autonomous_synthesis_enabled = falseconfig flag (defaultfalsefor daemon,truefor CLI) to make the feature opt-in until the ApprovalQueue lands.ApprovalQueueintegration 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
baseDirwith a stubLLMProvider. The threshold check, dup scan (word-overlap), atomic-write (tmpfile + rename), and<skillsDir>/skills.lock.jsonSHA-256 manifest update all work correctly. The feature itself is solid; it's just the daemon path that's lossy.Environment
zora-agentv0.12.0 (built from git, npm registry still on 0.11.0 per UPSTREAM.md)systemctl --userservice withEnvironmentFile=~/.zora/.envMinor doc nit
SkillSynthesizer.d.tsheader 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.