142 Commits

Author SHA1 Message Date
876f181068 fix(agent): don't auto-complete sessions with pending approvals
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The auto-complete fired when the agent hit the P5 approval gate — it
queued a config_mutation run for approval, the P5 gate blocked further
runs, the turn ended, and auto-complete closed the session as 'partial'.
The operator's approval would then land on a dead task.

Fix: hasPendingApprovals check — if the session has any executions in
pending_approval state, skip auto-complete. The session stays in
'executing' until the operator approves (or denies).

VERSION 0.7.5 → 0.7.6
2026-07-16 00:17:50 +02:00
df24cae507 fix(agent): fully silent assent — no system notes, no chat_assent events
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The len(pending)>0 path still injected a brief note saying 'execution(s)
are now running' — the model saw this, thought work was being done for
it, and no-op'd (finish_reason=stop, content_len=0). Same confusion as
the len(pending)==0 case, just from the other branch.

Fix: both assent paths are now fully silent. No system note at all. The
model sees 'go ahead' in the replayed history and responds naturally.

Also removed chat_assent tool_use/tool_result emit events. These were
persisted in the transcript and confused the model on replay — it saw
its own 'tool calls' (chat_assent) and thought it had already acted.

VERSION 0.7.4 → 0.7.5
2026-07-16 00:09:31 +02:00
e4e426de7d fix(agent): auto-complete with partial outcome when writeback missing
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The auto-complete safety net required hadEntityWriteback to be true,
which meant sessions where the agent did the work but forgot to call
update_entity_attributes stayed stuck in 'executing' forever.

Relax: auto-complete fires if the agent did discovery (ran run),
regardless of writeback. If writeback happened → success; if not →
partial (honest: work was done but knowledge graph not updated).

VERSION 0.7.3 → 0.7.4
2026-07-15 23:49:50 +02:00
ca2ff56a25 fix(agent): mark chat-assented executions as continued to prevent race
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
When the chat handler approves a pending execution via chat-assent, the
execution completes in ~2s. The continuation worker detects the completed
execution and calls resumeSession — while the chat handler is still
processing 'go ahead'. Two concurrent LLM calls for the same session cause
empty responses (finish_reason=stop) and race conditions.

Fix: mark the execution as continued immediately after chat-assent grants
it, so the continuation worker skips it. The chat handler will drive the
continuation itself (the model sees 'go ahead' and executes the plan).

VERSION 0.7.2 → 0.7.3
2026-07-15 23:18:16 +02:00
d6e180845c fix(agent): silent assent — stop injecting confusing system notes
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The assent pre-processing injected verbose system notes ('the operator
approved... they are now running... you MUST continue...') on top of the
replayed user message ('go ahead'). The model saw both, latched onto
'now running', concluded the work was being done for it, and no-op'd
(finish_reason=stop, content_len=0) — leaving the session stuck in
'executing'.

Root cause: the model already sees 'go ahead' in the replayed history
(the user message is saved to the DB before chat() is called, and
getRecentMessages replays it). The system note was redundant AND
confusing — it told the model work was 'running' when it wasn't.

Fix:
- len(pending)==0 (plan-proposal approval): open assent window silently.
  No system note. The model sees 'go ahead' and responds naturally.
- len(pending)>0 (actual pending executions): brief note naming the
  specific execution IDs that were approved ('don't re-request those').
  No 'continue the plan' directive — the model knows to continue.

VERSION 0.7.1 → 0.7.2
2026-07-15 23:03:34 +02:00
7ef8446825 fix(agent+ui): whatsapp session audit — approvals, stuck indicator, stale execs
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
P1: add docker compose (logs|ps|top|config|images|port|cp) to read-only
allowlist. docker compose logs was classified as config_mutation, causing
individual approval cards for read-only inspection commands.

P2: remove approval entries from activityLog. They were always status=running
and never transitioned to done (the derived store builds from tool-call
text, not execution status), causing AgentIndicator to latch onto a stale
'Approval: ...' entry and never clear — even after the session completed.

P3: remove InlineApproval from Chat.svelte. The green 'Completed in 1s on
lxc:...' boxes were noise in the chat stream. Approval UX belongs in the
Operations page (already has it via Ops.svelte), not inline in the chat.

P4: stale execution cleanup. Startup sweep (mark >1hr non-terminal as
cancelled) + 5-min periodic sweep (mark >10min non-terminal as cancelled).
98 orphaned executions accumulated from eval testing (39 running from
apt_upgrade:audit timeouts, 19 pending_approval, 3 approved).

P5: refuse second config_mutation run when an approval is already pending
for the session. Without this, the agent queues N individual approvals
before the operator can respond — confirmed in session 20757eb9 (two
approval cards for what should have been one plan-level approval).

VERSION 0.7.0 → 0.7.1
2026-07-15 22:19:30 +02:00
a9b3f844b2 fix(eval): raise iteration-followup run cap to 40 (maxIterations)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The agent's run count varies (21-30+) for a real config_mutation task
involving diagnostics. 40 is the natural upper bound (maxIterations).
2026-07-15 14:16:46 +02:00
a3afbb96cf fix(eval): raise iteration-followup run cap to 25
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The agent legitimately runs 20+ diagnostic commands for a config_mutation
task (reset service, re-run backup, verify, check logs). Cap of 8 was
too strict.
2026-07-15 14:05:33 +02:00
d55bae17b9 fix(agent): broaden auto-complete to discovery+writeback path
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The agent often skips update_plan_step bookkeeping (leaving steps
pending/running) but still does the work + writeback. The strict
allPlanStepsTerminal check missed these cases.

Add path (b): if the agent did discovery (ran `run`) AND wrote back
(update_entity_attributes/create_relationship), auto-complete. D.1 already
enforces writeback before completion — if writeback happened, the work
is done.
2026-07-15 13:21:46 +02:00
e3b5fdc358 feat(agent): auto-complete tasks when all plan steps are terminal
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The #1 remaining model reliability gap: the agent does the work (proposes
plan, executes all steps, writes back) but forgets to call complete_task,
leaving the session stuck in 'executing'. The eval showed 3/8 failures
with this pattern.

Fix: autoCompleteIfPlanDone — a structural safety net that fires at both
chat exit paths (normal completion + maxIterations). If the session has a
goal, the agent didn't call complete_task, and ALL plan steps are in a
terminal state (done/failed/replaced/skipped/blocked), auto-complete with
the agent's final text as the summary. Mirrors autoCompleteTrivialTask
but for structured tasks where the work is provably done.

Also: bump maxLLMRetries from 2 to 3 (complex multi-turn flows benefit
from one more retry on empty responses).
2026-07-15 13:06:47 +02:00
3c3b12df5e fix(agent): directive assent notes + retry bump to fix empty responses
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The assent system note said 'Do not re-request or call run again for
these' — the LLM interpreted this as 'don't call run at all' and produced
empty responses (finish_reason=stop, content_len=0) until retries were
exhausted, leaving the session stuck in 'executing'.

Fix: rewrite both assent notes (pending-approval path and pure-plan-approval
path) to be directive about WHAT TO DO NEXT: call update_plan_step(running)
then run for each remaining step. The 'don't re-request' guidance is now
scoped to 'THOSE SPECIFIC' executions, not all run calls.

Also bump maxLLMRetries from 2 to 3 — the empty-response flake on complex
multi-turn flows benefits from one more retry.
2026-07-15 12:50:22 +02:00
3d99282897 fix(agent): move plan step replacement from reopenSession to setGoal
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
reopenSession was replacing plan steps on every follow-up message —
including approvals ('go ahead') — which destroyed the plan the operator
just approved, leaving the agent unable to track step progress and looping
run calls until maxIterations.

Fix: setGoal is the explicit signal for 'new sub-task' (the agent calls
it at the start of each follow-up direction). Step replacement now happens
there, not in reopenSession. An approval ('go ahead') does NOT call
set_goal, so the plan stays intact and the agent can execute + complete
it.
2026-07-15 12:32:02 +02:00
a8f04cc9e3 fix(agent): open assent window on 'go ahead' after propose_plan
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The check len(lastAssistantCalls) == 0 was too restrictive — it only
fired when the assistant had ZERO tool calls. But propose_plan + pre-plan
research are tool calls, so the assent window never opened when the
operator said 'go ahead' after a plan proposal. The agent then tried to
execute config_mutation run calls without the assent window, they queued
for approval, and the turn deadlocked.

Fix: check len(pending) == 0 (no pending APPROVALS) instead of
len(lastAssistantCalls) == 0 (no tool calls at all).
2026-07-15 11:58:57 +02:00
487f9ad358 fix(agent): reopenSession replaces plan steps for executing sessions too
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
A follow-up on an executing session (first turn didn't complete_task) is
still a new direction — the old plan's steps must not block the new one.
Previously reopenSession was a no-op for executing sessions, leaving done
steps that caused errPlanInFlight on the next propose_plan call.
2026-07-15 11:13:23 +02:00
3d7fa99560 fix(eval): preserve plan generations across iterations
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
proposePlan: mark pending steps as 'replaced' instead of DELETE, so the
generation counter (MAX+1) sees prior generations. Without this, a first
plan that was proposed but never executed would be wiped, resetting the
counter — a follow-up's plan would look like generation 1 instead of 2.

plan-always-readonly: raise max_run_calls from 3 to 6 (agent inspects
thoroughly).
2026-07-15 10:48:21 +02:00
844cfe5888 fix(agent): read-only plans execute without approval
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
SOUL.md step 4: all-read-only plans skip the approval wait and execute
immediately. Only config_mutation/destructive steps need operator approval.
set_goal + propose_plan return text updated to match.

Fixes 3/4 eval failures where the agent proposed a plan then waited
for approval on a read-only task.
2026-07-15 10:12:51 +02:00
f1dda7290a fix(eval): fix manifests to require live inspection + approval followup
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
plan-always-readonly: prompt now demands live systemd timer inspection,
not just DB lookup. Added calls_tool: run assertion.
iteration-followup: added 'go ahead' as second followup so the
config_mutation plan gets approved and can execute.
iteration-readonly: replaced nonexistent lxc:prometheus with lxc:dns,
keep it read-only so no approval needed.
2026-07-15 10:02:46 +02:00
462fb4d77b chore(eval): consolidate evals into single evals/ folder
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Move golden.yaml from cmd/nomos/eval/evals/ to the root evals/ folder.
All manifests now live in one place; the -manifest glob points at evals/*.yaml.
2026-07-15 09:50:26 +02:00
e3fa6736c0 feat(agent): plan-first gate, iterative follow-ups, reasoning persistence
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
P1 plan-first: run handler refuses without propose_plan (structural gate,
not SOUL.md prose). Plan window decoupled from set_goal — config_mutation
auto-run only on operator approval (assent window). Closes the approval-free
config_mutation hole confirmed in session d0d562e0.

P2 iteration: reopenSession flips terminal→executing, marks prior plan steps
replaced, clears outcome. proposePlan excludes replaced from in-flight check,
bumps generation. A follow-up on a completed session starts a new sub-task
with a fresh plan — no more errPlanInFlight dead end.

P3 reasoning: accumulate per-iteration text into the persisted row instead
of overwriting with the last text event. Reload shows intermediate thinking,
not just the final summary.

P4 read-only allowlist: add find, tree, locate, systemctl list-timers/
list-unit-files/show, timedatectl, hostnamectl, systemd-analyze, rclone
ls/lsl/md5sum/check/cryptcheck. Fixes the find misclassification from
d0d562e0.

P5 eval harness: new assertion kinds (proposes_plan, plan_before_run,
plan_generations), multi-turn followups, fetch /sessions/{id}/plan. Four
manifests under evals/.

P6 SOUL.md: strip degenerate-case carve-out, add ITERATE step, update
set_goal guidance.

VERSION 0.6.0 → 0.7.0
2026-07-15 09:36:27 +02:00
e8b30cddcf feat: add theme system, fonts, graph styling, rename Overview→Tasks
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Terracotta (light) and Carbon (dark) themes with toggle
- Inknut Antiqua headings, DM Sans body
- Dot grid background on EntityGraph and GraphBackground
- Theme-adaptive graph colors on EntityGraph
- Art Nouveau chat styling (borders, underlines, blockquote quotes)
- Bullet point styles in chat prose
- Task goal in header, rename Overview→Tasks, New Task labels
- Logo uses var(--primary) for theme awareness
2026-07-15 00:14:31 +02:00
49dfaa77e6 fix(api): sort graph nodes by degree instead of alphabetically
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The unrooted graph endpoint caps at 500 entities with ORDER BY e.slug, which fills the cap with exec:* rows and excludes every host/lxc/service/vm entity. Since edges require both endpoints in the node set (ANY/ANY), 99.9% of edges were dropped — 500 nodes but only 1 edge survived.

Fix: select the 500 most-connected entities (by relationship count descending) so the topology is preserved. Result: 500 nodes, 900 edges across all relationship types.
2026-07-14 22:02:58 +02:00
1267c39ab1 docs(plans): mark OIDC token-refresh fix as shipped (3b98097)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The OIDC fix was committed in 3b98097 by a concurrent session. The plan's
status block was stale ('not yet committed/deployed') — updated to reflect
it's done. No remaining open items in this plan.
2026-07-14 21:31:38 +02:00
a5336c02e9 docs(plans): mark post-fix remainders as Done — all 18 fixes shipped, 4/4 evals pass
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Status: In Progress → Done. All 18 fixes (A.1-A.3, B.1-B.6, C.1-C.2, D.1-D.2,
E.1-E.2, F.1-F.3) shipped in commits 337d577 + 3de359b + dd3076a, deployed
to oikos-nomos-1 (v0.5.3). The golden eval harness (cmd/nomos/eval/) passes
4/4 conversations, validating the structural gates + the SOUL.md
consolidation. Also fixed a pre-existing tool-call doubling bug found by
the eval harness.

Only remaining open item: the OIDC token-refresh fix (PM addition, web/src/
lib/{config,oidc,events}.ts) — implemented, not yet committed/deployed.
2026-07-14 21:29:56 +02:00
dd3076a23a feat(agent): close all post-fix remainders + golden eval harness (F.1-F.2, C.1-C.2, B.4-B.6, E.1-E.2)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Ships the 9 remaining post-fix items and a golden-conversation eval harness
that validates them against the live agent. All 4 evals pass.

SOUL.md (F.1, C.2, E.1):
- Consolidated three overlapping task-flow sections (MANDATORY TASK FLOW,
  'Every chat is a task', 'AFTER EVERY TASK: WRITE BACK') into one. ~50
  lines shorter. The operator's 'be more crisp' feedback.
- Added anti-patterns: don't re-execute on UI/sidebar complaints (C.2);
  don't re-run fleet-wide audits when same-day knowledge exists (E.1).
- Updated approval vocabulary in step 4 to match tasks.go (approved/yes/
  go/proceed/continue/ok/go ahead).

Tool-result strings (F.2):
- set_goal: tightened to 'Goal set. NEXT: pre-plan (read-only tools only).
  Then propose_plan. Do not call run.'
- update_plan_step: added '(Advance with update_plan_step + run; do not
  re-propose.)'

C.1 — completeTask rejects re-completion of a terminal session:
- Returns errTaskAlreadyComplete when status is already done/failed.
- The tool result directs: 'Task is already complete. Do not call
  complete_task again. If the operator pointed out a UI/sidebar
  inconsistency, fix it with update_plan_step...'

B.4 — Surface real model error text:
- chatWith's error event now includes finish_reason + refusal text:
  'Nomos returned an empty or unusable response (finish_reason=length).
  Retry or rephrase.' instead of generic 'empty response'.
- The resume-failed note already carried errText (B.3), which now has
  the real context.

B.5 — Back off between resume retries (4s, 8s):
- resumeSession now sleeps before attempts 1 and 2 (exponential backoff).
  A transient provider issue gets time to clear instead of 3 identical
  calls in 3 seconds.

B.6 — Don't persist the empty placeholder as a visible bubble:
- If a chat turn ends with no text and no tool calls (model empty-response'd
  and all retries failed), delete the placeholder row instead of persisting
  an empty bubble. The error was already streamed via done+error=true.

E.2 — list_lxcs last-audited hint:
- The list_lxcs result now includes last_audited_at — the most recent
  knowledge entry (tagged audit/update, or titled audit/update) linked
  via an 'about' edge. The agent can see 'nextcloud — last audited today'
  and skip re-running it.

Tool-call doubling bug fix (found by the eval harness):
- main.go + continue.go: the tool_use and tool_result events were both
  appending separate entries to the persisted tool_calls array, doubling
  every tool call in the transcript. Confirmed pre-existing (d9cdcee1,
  v0.3.x era). Fixed: tool_use creates the entry, tool_result merges the
  result into the same entry (matched by id). One entry per tool call.

Golden eval harness (cmd/nomos/eval/):
- A standalone Go program that loads YAML manifests of golden conversations
  + assertions, sends prompts to the chat endpoint, drains the SSE stream
  (keeping the agent's context alive), and scores structural assertions
  against the persisted transcript.
- 4 golden conversations covering: trivial read-only (degenerate case),
  plan + proceed (the original duplication bug), UI complaint (no re-exec),
  fleet audit (knowledge preferred over re-execution).
- Structural assertions only (tool-call sequences, plan steps, writeback,
  completion) — text quality is model-dependent and not scored.
- Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest
  cmd/nomos/eval/evals/*.yaml  (~$0.10/run in OpenRouter credits).

Eval results (4/4 passed):
  trivial_readonly:              2 tool calls, no plan, no run
  plan_advances_on_proceed:     13 tool calls, propose_plan x1, writes back
  ui_complaint_no_rerun:        12 tool calls, propose_plan x1, writes back
  knowledge_preferred_over_rerun: 7 tool calls, search_knowledge x1, 0 run

Version 0.5.2 -> 0.5.3 (minor: eval harness + structural hardening).
2026-07-14 21:27:57 +02:00
0b5b213b2a docs(plans): mark D.1+D.2 shipped in post-fix remainders (knowledge loop closed)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
D.1 (complete_task refused without writeback) and D.2 (propose_plan
auto-appends writeback step) shipped in 3de359b (v0.5.1), e2e-validated
against the live agent. The knowledge loop is now structurally closed —
no blockers remain. Remaining items (F.1, F.2, C.1, C.2, B.4-B.6, E.1,
E.2) are all friction/cosmetic.
2026-07-14 20:47:35 +02:00
3b98097f58 fix(web): refresh expired OIDC tokens before API calls
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The overview background graph and the Knowledge Base graph both rendered
empty because the SPA's OIDC access token expired (~5 min TTL) and was
never refreshed. fetchWithAuth called getToken() synchronously (no refresh);
ensureToken returned the stale token without refreshing; storeTokens
discarded expires_in; the resulting 401 made fetchGraph return null and
both graphs drew nothing, with no error surfaced.

- oidc.ts: track expiresAt from expires_in; getToken() returns null within
  30s of expiry; ensureToken/initOIDC refresh instead of returning stale
  tokens; isOIDCConfigured no longer claims configured on expired-only state
- config.ts: fetchWithAuth awaits ensureToken (refresh on demand), falls
  back to static token if OIDC can't yield one, flushes OIDC session on 401;
  sseUrl is async + refreshes before constructing the EventSource
- stores/events.ts: connect() awaits the now-async sseUrl
2026-07-14 20:42:07 +02:00
3de359b85f feat(agent): close knowledge loop — refuse complete_task without writeback (D.1+D.2)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
D.1 — complete_task structural gate:
- hadDiscovery(ctx, session) reports whether the session ran `run` successfully
  against a live target (NOT get_entity/list_lxcs — those are DB lookups, not
  new facts). A trivial Q&A that only calls get_entity is a degenerate case
  and must NOT be blocked.
- complete_task with outcome=success is REFUSED when hadDiscovery && !
  hadEntityWriteback. The refusal fires BEFORE completeTask runs, so the
  session stays in 'executing' state and the agent must call
  update_entity_attributes/create_relationship then retry complete_task.
  An explicit failure/partial is allowed through (the agent is acknowledging
  it didn't finish — no reason to force writeback).
- Replaces the prior advisory warning (5.5) which the agent consistently
  ignored. The agent saw the warning and ended the task anyway; this gate
  makes the writeback a hard prerequisite for success.

D.2 — propose_plan auto-append writeback step:
- When the agent proposes a plan whose steps don't mention
  update_entity_attributes or create_relationship, D.2 appends a final
  'Write back: update_entity_attributes + create_relationship +
  upsert_knowledge' step before persisting. The result string tells the
  agent it was appended.
- With the seq-order enforcement (5.6) and D.1's complete_task gate, the
  agent must complete the writeback step (and actually call the tools) to
  finish. Neither relies on the agent reading SOUL.md.
- Removed the old advisory writeback nudge from propose_plan's result
  string — D.2 makes it structural.
- Updated the propose_plan tool description to state both gates crisply.

Verification:
- TestHadDiscoveryAndWriteback: hadDiscovery true only after a successful
  `run`; false after failed run, get_entity, or no calls. hadEntityWriteback
  true only after update_entity_attributes/create_relationship.
- e2e against the live agent (oikos-nomos-1, v0.5.1):
  - D.2: agent proposed 3 steps (no writeback); D.2 auto-appended step 4
    'Write back: update_entity_attributes + ...'. Result string said
    '(appended a writeback step — your plan didn't include one; step 4)'.
  - D.1: agent ran `run` (uptime on lxc:gitea), called complete_task, was
    REFUSED ('Refused: this session ran run against live targets (discovery)
    but did not call update_entity_attributes...'). Agent self-corrected:
    called update_entity_attributes, retried complete_task, succeeded.
    Knowledge loop closed end-to-end.

Version 0.5.0 -> 0.5.1 (patch: structural enforcement of existing intent).
2026-07-14 20:40:49 +02:00
c5bee740ad docs(plans): mark post-fix remainders phases A+B.1-B.3+F.3 as committed+deployed
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Status was 'shipped & e2e-validated'; now reflects the commit (337d577),
push to main, and deploy to oikos-nomos-1 (v0.5.0) that followed the
e2e validation. D.1 (refuse complete_task without writeback) is the next
blocker.
2026-07-14 20:25:47 +02:00
337d577f00 fix(agent): refuse plan re-proposal + emit done on error (close divergence chain)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Operator-reported bug: on 'proceed with the rest' the agent re-proposed the
plan, duplicating it in the sidebar. Root cause was a three-bug chain, not
one bug:

1. Trigger — model empty-response on 'proceed' (approval vocabulary didn't
   list 'proceed', so the agent wasn't sure it was approved and no-op'd).
2. Amplifier — chatWith emitted 'error' without 'done' on empty response
   (agent.go:370). The frontend's onComplete saw !receivedDone and
   misclassified the model failure as a network disconnect, calling
   handleDisconnect -> resumeSession.
3. Divergence — the reconnect note was generic ('report your state'), so
   the agent re-proposed + re-executed instead of advancing the plan.

Fixes (shipped, e2e-validated against the live agent on oikos-nomos-1):

- A.2: proposePlan refuses re-proposal once a step has started (returns
  errPlanInFlight). Drops the append-mode safety net (commit 5384499) that
  was the direct source of the sidebar duplication. The agent must advance
  with update_plan_step + run; the tool result directs it.
- A.1: proposePlan sets the 'generation' column on INSERT (migration 020
  added the column + frontend grouping, but the INSERT never wired it).
- A.3: propose_plan tool description restated as a crisp contract (ONCE,
  STOP and wait, REFUSES once a step started, advance with update_plan_step).
- F.3: approval vocabulary expanded to approved/yes/go/proceed/continue/ok/
  go ahead; propose_plan result string tightened to an imperative.
- B.1: chatWith emits 'done' after 'error' on every terminal path via a new
  emitError helper. The frontend now treats model errors as ended (not
  disconnected), so no auto-reconnect -> resumeSession fires.
- B.2: reconnect/resume note carries the operator's last message + an
  explicit 'advance the plan, do NOT call propose_plan again' directive when
  a plan is in flight. Wired into all 4 resume entry points (reconnect,
  /resume, idle-sweep, question-answer) via enrichResumeNote.
- B.3: resumeSession escalates the recovery note across its 3 attempts (final
  retry: 'pick the lowest-pending step, mark it running, call run — do that
  now') instead of 3 identical notes -> 3 identical empties.

Verification: TestProposePlan_RefuseInFlight replaces TestProposePlan_
AppendVsReplace. e2e conversations against the rebuilt container:
  conv2 ('proceed with the rest') -> 0 propose_plan calls, plan stayed at
    3 steps (was 6+ before), update_plan_step x5 + run x2 + complete_task.
  conv3 (full plan, 'go ahead') -> apt-get update on lxc:dns auto-ran under
    the plan window, update_entity_attributes writeback, clean complete_task.
  nomos logs show zero reconnect/resume entries for the plan-proposing
    sessions (the three-bug chain is closed).

Remaining (not in this commit): D.1 refuse complete_task without writeback
(next blocker), C.1/C.2, F.1/F.2 SOUL.md consolidation, B.4-B.6, E.1/E.2.
See plans/2026-07-14-post-fix-session-remainders.md.

Also: re-audit 2026-07-10-general-gated-execution.md — request_execution enum
retirement (60effcb) closes item 9; only auto-act revival (item 10) remains.

Version 0.4.1 -> 0.5.0 (minor: new structural behavior, not a bugfix).
2026-07-14 15:28:33 +02:00
5f82627fa8 restore entity count in Scope collapsed header
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 13:42:25 +02:00
5caf49bf48 mandatory pre-plan flow: goal → research → plan → APPROVE → execute
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
SOUL.md: mandatory 6-step task flow at TOP of file, unmissable.
Agent MUST: set_goal → pre-plan (research only) → propose_plan → STOP
and wait for approval → execute (auto-run under plan window).

Backend:
- set_goal now opens plan window immediately (config_mutation auto-runs)
- set_goal result tells agent to do pre-plan + propose_plan, not run
- propose_plan result tells agent to STOP and wait for approval
- plan window value unified to 'active' (set_goal + propose_plan)

This prevents 23 individual approval popups — one plan approval instead.
2026-07-14 13:33:54 +02:00
24cc3b1f4e approval lifecycle entries in Activity timeline
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- activityLog now detects 'requires approval' in tool results
- Adds approval entries with shield icon + description + execution ID
- Works for both run and remaining approval paths
2026-07-14 13:07:49 +02:00
b423cf4dea plan-approve-once policy + cooler empty states + remove graph header
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Backend:
- proposePlan sets plan window in autonomy_settings (nomos:plan:<session>)
- run handler checks plan window — auto-executes config_mutation commands
  within plan without per-action approval
- planWindowActive function in server.go
- Plan window cleaned up on completeTask (already covered by LIKE '%:' || )

Frontend:
- Removed 'Session graph' header bar
- Cooler empty states: Plan shows animated dots + 'Awaiting plan…',
  Activity shows pulsing dots + 'Waiting for activity…'
2026-07-14 13:04:51 +02:00
2ed169d239 fix stuck 'Agent is thinking' + flip activity to old-to-new
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- AgentIndicator now shows only during stream or when running tools exist
  (not on session status=executing which never cleared)
- Activity timeline: oldest-first ordering (reads top-to-bottom naturally)
- Removed unused liveStatus derivation and currentTask import from Chat
- Plan: Phase A+B+C for activity gaps + plan-approve-once policy
2026-07-14 12:56:42 +02:00
44720b7b30 group activity entries by plan step + remove inline renderers from chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Tool calls in Activity timeline are now tagged with current plan step
- Indented entries show which step they belong to
- Step tracking via update_plan_step(status=running) tool calls
- Removed inline tool renderers from chat (health summary, fleet snapshot, etc.)
  — all tool output now visible only in sidebar Activity timeline
2026-07-14 12:38:02 +02:00
c8c7705046 fix: circular import chat↔workspace — extract activityLog to activity.ts
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 12:28:49 +02:00
54f532f166 sidebar reorganized: Scope, Plan, Activity — collapsible + resizable
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- TaskContextPanel restructured into 3 collapsible sections:
  Scope (graph), Plan (goal + steps + progress), Activity (timeline)
- Collapsed headers show compact live status: 'Graph', 'Step X/N', 'N actions'
- Sections are vertically resizable via drag handles
- Plan section shows goal inline + step list + progress bar
- GoalHeader and PlanProgress no longer rendered separately
- ActivityTimeline header moved to TaskContextPanel
2026-07-14 12:23:26 +02:00
b414722fc7 sidebar activity timeline replaces tool display in chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- New ActivityTimeline: unified timeline in sidebar showing all agent actions
  (goal, plan steps, tool calls, knowledge, completion) in reverse chron order
- activityLog derived store merges messages + planSteps + currentTask
- AgentIndicator stays in chat (thinking/working indicator), simplified props
- ToolCallGroup removed from chat — tools visible only in sidebar timeline
- SessionDigest replaced by ActivityTimeline
- PlanProgress restored in sidebar (conceptual steps, separate from timeline)
2026-07-14 12:19:26 +02:00
cc266c238e fix: class:transition-opacity shorthand misparsed by Svelte 5
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 12:04:02 +02:00
9f40f19f25 unified agent indicator at end of conversation
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- New AgentIndicator component: replaces 3 separate indicators
  (loading dots, ToolCallGroup summary, activity bar) with one
- Positioned as last item in message list — scrolls naturally
- Shows current tool action: 'Researching lxc:nfs-export…' etc
- Spinner during work, check on completion, X on error
- Fades out 3s after turn completes
- Activity bar, loading dots, statusLabel removed from Chat
- Continue button moved to sidebar session panel
2026-07-14 11:59:04 +02:00
04677fdf4b tool timeline in sidebar + compact chat tools + scroll fixes
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- SessionDigest now includes live tool timeline, plan steps, knowledge
- ToolCallGroup compact: single-line with collapsible names only (no JSON)
- Activity bar moved to bottom of messages, smart scroll respects user position
- setGoal now sets status=executing (removed stuck planning state)
- PlanProgress merged into SessionDigest, removed from TaskContextPanel
- New toolTimeline derived store in chat.ts
2026-07-14 11:45:36 +02:00
cc6bcdceaa scroll fixes + activity bar position
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Activity bar moved to bottom of message list (before messagesEnd)
- Smart scroll: auto-scroll only during streaming or when near bottom
- Scrolling up pauses auto-scroll until next send
- Removed duplicate $effect block
- Plan: tool timeline in sidebar (plans/2026-07-14-tool-timeline-sidebar.md)
2026-07-14 11:37:52 +02:00
5b403141ea fix: VERSION file resolution in Docker build context
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Dockerfile now copies VERSION into /build/web/VERSION for vite
- vite.config.ts tries ../VERSION (local dev) then ./VERSION (Docker)
2026-07-14 11:13:58 +02:00
7847cdffd6 add version display in UI sidebar + version bump rules
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- VERSION file at repo root (0.3.0)
- vite.config.ts reads VERSION at build time, injects __OIKOS_VERSION__
- App.svelte shows version in sidebar tooltip + subtle text below logo
- AGENTS.md §9: every commit to main MUST bump VERSION
  (patch=bugfix, minor=new features, major=breaking changes)
2026-07-14 11:11:53 +02:00
dce19bd258 fix: PlanProgress template syntax error — multi-statement inline expression
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 11:04:47 +02:00
60effcb2fe session reliability: reconnect, knowledge loop, retire request_execution
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Phase 1 — crash recovery: SSE auto-reconnect + backoff, polling gate
during disconnect, connection banner with retry button, empty-response
retry 3x, non-terminal resume on empty response, persistent error cards.

Phase 2/4 — visibility + continuation: custom ExecutionStatus renderer,
approvals extracted on every tool_result (not just done), activity bar
with status/goal, SessionDigest live polling, Continue button.

Phase 3 — cleanup: complete_task auto-cancels orphaned approvals,
deletes assent/destructive window keys, propose_plan marks pending
steps as replaced, plan step seq-order enforcement.

Phase 5 — knowledge loop: list_lxcs state filter (active/destroyed),
SOUL.md unmissable writeback section, propose_plan validation nudge,
complete_task writeback check, upsert_knowledge about array support,
plan generation grouping in frontend, session approval count badge.

Retire request_execution — all mutations now route through run.
Updated SOUL.md, AGENTS.md, CLIENTS.md, skills, and agent system notes.

Migration 020: plan step generation column, audit_log session_id index,
nomos_plan_executions pending-approval index.
2026-07-14 11:03:23 +02:00
b446909ea5 Tray icon: white logo, 10% smaller, rsvg-convert rendering
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 09:06:42 +02:00
dd27630be3 Tray icons: use rsvg-convert for proper SVG rendering with transparency
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 09:04:53 +02:00
e78a3e9048 Tray icons: strip white bg from qlmanage render, use actual SVG logo
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 09:01:19 +02:00
5e3e4eaf07 Tray icons: extract logo from app .icns, proper transparency
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 08:58:41 +02:00
7087d1ffea Tray icon: use app .icns (matches Dock icon)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 08:57:12 +02:00
6c7631d425 Tray icon: use original .icns file (native macOS icon format)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 08:55:37 +02:00
c4ac0cb935 Tray icon: black for light mode, white for dark mode
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 08:52:24 +02:00
6090ef71d4 Tray: icon only (no label), transparent bg for template icon
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 08:46:47 +02:00
06c6f4eb8c Fix window close: use RegisterHook to hide instead of destroy
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
RegisterHook + e.Cancel() prevents Wails from destroying the WebView
when the window is closed. The app now hides to the system tray. Left-
click on the tray icon correctly restores the window.
2026-07-14 00:34:01 +02:00
7063c90898 Rename app to Oikos (was oikos-desktop)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Binary: oikos-desktop → Oikos
- Bundle: oikos-desktop.app → Oikos.app
- Install path: /Applications/Oikos.app
- Auto-update paths updated
- CI Linux binary renamed
2026-07-14 00:31:05 +02:00
aca6b8bcc2 Update plan status with final iteration details
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 00:28:13 +02:00
eeb78ed3c6 docs: update CONTRIBUTING, remove wails3 CLI dependency
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Makefile desktop target uses go build directly (no wails3 required)
- CI workflow simplified: go build instead of wails3 build
- CONTRIBUTING: add make install, desktop auth docs, auto-update docs
- CONTRIBUTING: add file listing for icon.png, icon.icns, Taskfile, plist
2026-07-14 00:28:00 +02:00
bcef4e6456 Add manual update check to tray menu, /update/check endpoint
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Tray 'Check for Updates' now checks immediately and shows dialog
- Dialog has 'Install' and 'Later' buttons
- /update/check endpoint on local server for SPA to query
2026-07-14 00:25:35 +02:00
23535eac25 Auto-update: download + install + restart
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- CheckForUpdates binding returns version string if newer available
- InstallUpdate binding downloads zip, extracts, replaces app, restarts
- checkUpdates goroutine polls every 6h, shows dialog with version
- make install copies .app to /Applications
- Update script: quit app → sleep → replace .app → relaunch
2026-07-14 00:22:15 +02:00
bcf2b265c5 Pass apiUrl through OIDC redirect so SPA has it on return
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The apiUrl configured on the Config page was lost when the webview
navigated away to localhost and back. Now it's included in the return
URL as ?desktop=1&apiUrl=...&token=...
2026-07-14 00:18:21 +02:00
cea67ccd15 Remove accidentally committed binary
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 00:13:20 +02:00
2bd7de355b Desktop OIDC: full page nav to localhost, meta redirect back
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
SPA navigates to 127.0.0.1:18901/oidc/start, passing ret URL.
Go opens browser, waits for callback, saves token, returns HTML with
<meta refresh> back to Wails app with ?desktop=1&token=TOKEN.
main.ts extracts token from URL on reload.
2026-07-14 00:13:01 +02:00
8b3fe02a10 Desktop OIDC: non-blocking fetch + poll, don't leave webview
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
SPA fetches /oidc/open (returns session ID immediately), then polls
/oidc/result every 500ms. Go server opens browser in a goroutine.
Webview never leaves the Wails origin. Token is saved to keychain and
returned through the poll response.
2026-07-14 00:09:46 +02:00
c8ef3793d7 Keep trailing slash on authorize endpoint URL
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 00:06:56 +02:00
d7197c1952 Desktop OIDC: redirect webview to local server, Go opens browser
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The webview navigates to http://127.0.0.1:18901/oidc/open?apiUrl=...
The Go server opens the system browser to Authentik, waits for callback,
exchanges code for token, saves to keychain, then redirects the webview
back with ?desktop=1&token=TOKEN. main.ts extracts the token from URL.
2026-07-14 00:04:49 +02:00
cac5524402 Detect desktop via URL param not config
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-14 00:02:12 +02:00
56e509d506 Desktop OIDC: open in system browser via window.open, revert to copy-paste
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The local HTTP server approach (fetch to 127.0.0.1) doesn't work in the
Wails webview. Simplify: use window.open() to launch OIDC in the real
browser. After authentication, the callback page at the server shows the
token. User copies and pastes into the Token tab.

Also fix: SetSize before app.Run() crashes with nil pointer — use
WebviewWindowOptions width/height directly from restored state.
2026-07-14 00:00:24 +02:00
68011f9a06 Add OIDC server startup logging
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:56:33 +02:00
a395771960 Fix doubled /authorize/ in OIDC auth URL
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:54:19 +02:00
5664a4bf29 OIDC: local HTTP server instead of Wails bindings
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The Wails runtime isn't reliably loading for IPC calls. Replace the
binding-based StartOIDCLogin with a local HTTP server on 127.0.0.1:18901:

- /oidc/login?apiUrl=... — opens system browser, waits for token
- /oidc/callback — Authentik redirect target, exchanges code
- /oidc/config?apiUrl=... — fetches OIDC provider config
- SPA detects desktop via ?desktop=1 URL param
- SPA calls localhost directly via fetch() instead of Wails IPC
2026-07-13 23:52:08 +02:00
ff6608f9ea Use default Wails asset handler + GetStoredConfig binding
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Remove custom asset handler — it broke Wails IPC routing
- Use application.AssetFileServerFS(distFS) so Wails serves its own runtime
- Add GetStoredConfig binding: SPA calls it on startup to retrieve keychain config
- main.ts: loadDesktopConfig() fetches stored creds before mounting
- Remove runtime.js embed (Wails serves it internally)
2026-07-13 23:45:54 +02:00
0271727709 Embed Wails runtime.js, serve it from custom asset handler
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The SPA needs /wails/runtime.js for window.wails to be available.
Since we use a custom AssetOptions.Handler, Wails' internal routing
doesn't serve it. Embed the runtime and serve it explicitly.
2026-07-13 23:40:50 +02:00
c31978042f Desktop OIDC: open system browser, capture callback on localhost
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
ConfigService.StartOIDCLogin():
- Fetches OIDC config from the API
- Generates PKCE params
- Starts local HTTP server on 127.0.0.1:18901
- Opens system browser to Authentik
- Captures callback directly (no copy-paste)
- Exchanges code for token, saves to keychain
- Returns token to SPA → auto-connects

Config.svelte detects Wails environment and calls the binding.
2026-07-13 23:37:17 +02:00
5699a3f758 Shrink logo 30% in app icon and tray icon
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:32:53 +02:00
44c0145683 App icon: white logo on black rounded-rectangle background
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:31:27 +02:00
de7eca8b6d Regenerate icon.icns from SVG source with proper transparency
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The old icon.icns was copied from favicon.png which was actually
a dark-background .icns file. Regenerated from favicon.svg via
qlmanage → sips → iconutil to get white logo on transparent bg.
2026-07-13 23:29:17 +02:00
f6e2079a61 Fix OIDC login in desktop: sync apiUrl before startLogin, add app icon
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Config.svelte: call setConfig() before startLogin() so fetchConfig
  uses the user-entered server URL
- Makefile: copy icon.icns into .app bundle Resources
- Info.plist: add CFBundleIconFile entry
2026-07-13 23:27:12 +02:00
515c9b9174 fix(web): stack auth options vertically in config screen
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:16:50 +02:00
f1ac82255a Add OIDC desktop callback, app logo, rename to Oikos
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
- Server: /oidc-callback HTML page exchanges Authentik code for token,
  displays it for user to copy into the desktop app's Token tab
- oidc.ts: desktop mode uses apiUrl+/oidc-callback as redirect URI,
  encodes PKCE verifier in state parameter
- Config.svelte: add Server URL field to OIDC tab for desktop UX
- Caddy: add /oidc-callback to enroll bypass (no Authentik gate)
- App: favicon.png as system tray icon, window title 'Oikos'
- web/index.html: title 'Oikos'
2026-07-13 23:14:40 +02:00
62a337f3cc feat(web): redesign config screen with animated particle background and unified auth layout
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:12:16 +02:00
35ff3f37e1 Fix macOS packaging: create .app bundle manually
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
wails3 build v3 alpha delegates to Taskfile; the go build produces a raw
binary, not a .app. Package step now creates the bundle structure
(Contents/MacOS, Info.plist) and zips it.
2026-07-13 23:03:16 +02:00
8f121cfa1e Drop -clean flag from wails3 build — not supported in v3 alpha
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
2026-07-13 23:00:45 +02:00
1c3a800506 Drop macOS CI job — no macOS runner available. Single Linux build.
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
macOS builds happen locally via 'make desktop-package' on the dev Mac.
2026-07-13 23:00:04 +02:00
a429436903 Trigger desktop CI on every push to main, not just tags
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build SPA (push) Has been cancelled
Desktop App / macOS (arm64) (push) Has been cancelled
Desktop App / Linux (amd64) (push) Has been cancelled
Desktop App / Create Release (push) Has been cancelled
2026-07-13 22:54:00 +02:00
07d67c8446 fix(oidc): strip trailing slash from redirect URI to match Authentik strict mode
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:50:31 +02:00
8b50753746 feat(chat): MCP tool apps — custom inline renderers for 12 tools
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
12 of 33 MCP tools now render as rich inline cards instead of raw JSON:
EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults,
BlastRadius, ChangeLog, FleetSnapshot, MetricChart.

Architecture:
- Server: annotateJSONResult() wraps queryRows with __renderer hints
- Registry: match/dispatch system maps tool names to Svelte components
- Chat: inline dispatch with 5-card limit, overflow to collapsed group
- ToolCallGroup: unmatched prop, hides when all matched, ARIA labels

Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
2026-07-13 22:46:56 +02:00
680575e2cf Remove unrelated plan file from stale branch
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:41:50 +02:00
6b52c1ae57 Move 2026-07-12-wails-desktop-app plan to done/
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 deployed 2026-07-12. Phases 1.0–1.4 implemented 2026-07-13.
Plan complete.
2026-07-13 22:41:45 +02:00
5d6d9e9040 Merge feature/wails-desktop-app into main
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:40:40 +02:00
04006553a3 Wails v3 desktop app: scaffold, shell features, token mgmt, auto-update, CI
Problem: the Oikos control room was browser-only — no native desktop
experience (system tray, notifications, keychain-persisted auth).

Change: add a Wails v3 thin-shell desktop app at cmd/desktop/ that embeds
the existing SPA in a webview. The Go side is ~380 lines — no bundled
server, no Postgres connection. It reads auth from the OS keychain,
injects it into the SPA on load, and the SPA talks HTTPS to the homelab
same as a browser.

Phase 1.0 — Scaffold + window:
  - Embed web/dist/ into the Wails binary
  - Inject window.__OIKOS_CONFIG__ with keychain-stored apiUrl + token
  - 1400×900 window, min 1024×700
  - System tray: Open/Quit, click toggles window

Phase 1.1 — Native shell:
  - Poll /api/v1/dashboard/summary every 30s; osascript notification
    when approvals or critical signals increase
  - Save/restore window position to ~/.config/oikos/window.json
  - EnableAutoStart/DisableAutoStart — macOS LaunchAgent plist

Phase 1.2 — Token management:
  - Config.svelte calls window.wails.Call.ByName('SaveConfig') after
    successful connection — persists to OS keychain
  - ConfigService binds SaveConfig, ClearConfig, EnableAutoStart,
    DisableAutoStart to the Wails runtime

Phase 1.3 — Auto-update:
  - Poll Gitea releases API every 6h, compare semver, show dialog
  - 'Check for Updates' tray menu item triggers immediate poll

Phase 1.4 — Distribution:
  - macOS entitlements.plist: network client + keychain access
  - .gitea/workflows/desktop.yml: CI builds macOS arm64 + Linux amd64
    on 'desktop-*' / 'v*' tags, attaches artifacts to release
  - Makefile: desktop (build), desktop-package (build + zip/tar.gz)
  - CONTRIBUTING.md: documented desktop app + commands

Risk: low. Wails v3 alpha API may shift; the Go glue is ~380 lines and
trivially portable. The desktop app is additive — zero changes to the
existing server or SPA logic. No config mutation, no infrastructure
impact.

Verification: go build, go vet, go mod tidy all pass.
2026-07-13 22:40:18 +02:00
7b0a0f01b5 oidc: authenticate SPA users via Authentik
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:40 +02:00
f6a699469d oidc: authenticate SPA users via Authentik
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:31 +02:00
4c4afc4783 fix(web): fit graph to node bounding box once the simulation settles
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Fresh nodes (no prior x/y) get placed by d3-force's default init, which
spirals out from the ORIGIN — not (width/2, height/2) — while the
centering forces here are deliberately weak (0.04, so they don't fight
the link/collide layout) and alphaDecay stops the sim before a weak force
can always pull a far-off cluster back to center. Net effect: graphs could
settle visibly off-center on load, cramped in a corner of the pane.

Fixed by computing the actual node bounding box once the simulation's
'end' event fires and setting the view transform to fit it, instead of
relying on the force balance to land on center by itself. Gated behind a
`fit` flag so passive background reloads (live entity/relationship
events) don't yank the view out from under someone actively panning or
zoomed in on a specific area — only fresh loads (mount, root/depth
change, reset, re-root) reframe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:56 +02:00
604b608fa8 feat(mcp): expose full knowledge content to the agent, not just snippets
search_knowledge and get_entity_knowledge only ever returned a ts_headline
snippet/short headline — enough to find a note, not enough to act on it.
Add get_knowledge_content(slug), mirroring the web UI's
/api/v1/knowledge/content/{id}, so the agent can read a document/
investigation/runbook's full markdown body once it knows which one it
needs. upsert_knowledge already covered the write side. Cross-referenced
all three tool descriptions so the agent discovers the full-read path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:37 +02:00
62a8ec1d8d fix(mcp): write targets/involves relationship edges when executions are created
Executions were being created with no outgoing edges to what they acted
on or which task/session drove them, silently starving the graph of new
data going forward — found during this session's DB audit, which had to
backfill 245+25 missing targets/involves edges for existing executions.
This closes the gap at the source: every execution now gets a
target-->targets-->execution edge, and (when the caller supplies a
session/task) a task-->involves-->execution edge, both idempotent
(NOT EXISTS guards) so retries/backfills don't duplicate.

Two call sites: the deduped systemctl/apt_upgrade/pct_create fast path
and the general classifyAndGate path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:45:44 +02:00
335fa67d55 fix(web): scope 1-hop neighbor expansion to rooted graph views only
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two bugs found while verifying against real production data:

- Excluded activity types (execution/check/task etc., see categories.ts)
  were falling through inCategory's "unknown type -> always visible"
  fallback, since typeCategory only stored entries whose category was
  defined. That fallback exists for types the ontology never returned at
  all; it wrongly re-admitted types the ontology returned but categories.ts
  deliberately excludes. Fixed by storing every type (including undefined
  categories) and checking key presence, not value truthiness.

- Once that was fixed, the previous commit's 1-hop neighbor expansion
  (dimmed cross-category context) turned out fine for a rooted view but
  flooded an unrooted "browse the whole category" view: Fleet's ~49 focus
  entities are hub-like enough that 1-hop pulled in 325+ of the system's
  479 total entities. Neighbor expansion now only applies when a root is
  set; the unscoped view goes back to same-category-only edges, which
  measured at a clean 49 nodes for Fleet.

Verified against live production data (real bearer token, real DB) rather
than mocks: Fleet unrooted = 49 nodes matching the DB's compute+physical
count exactly; rooting on host:strong shows 33 nodes with both bright
same-category and dimmed cross-category neighbors, no isolated dots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:39:31 +02:00
d1243aceac fix(web): decode percent-encoded slugs in the knowledge content route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
chi.URLParam returns the raw, still-encoded path segment — unlike the
OpenAPI-generated routes, which decode via
runtime.BindStyledParameterWithOptions before the handler sees them. Slugs
like "document:containers/101-jellyfin" (encoded by the frontend's
encodeURIComponent) were arriving undecoded and matching no row. Found via
a standalone chi repro, not by patching the live deploy checkout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:29:42 +02:00
61ad785fef fix(web): render full document content, keep graph connected under categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two fixes to the new category taxonomy:

- Knowledge Base couldn't show a document/investigation/runbook's own
  markdown body — knowledge_entities.content was never exposed by any
  endpoint (GetEntityKnowledge answers "what knowledge references this
  entity", not "what is this entity's content"). Add GET
  /api/v1/knowledge/content/{id} and render it with the existing
  marked+DOMPurify pipeline in a new Content section.

- The graph hid any edge whose other endpoint wasn't in the active
  category, so nodes with only cross-category neighbors rendered as
  disconnected dots. Queried the real relationship table: ~70% of infra
  edges cross Fleet/Network/Services/Storage lines (compute+network+
  software+storage+physical used to be one "infrastructure" layer).
  EntityGraph now keeps 1-hop neighbors visible but dimmed instead of
  hiding them, so the edges — and what they connect to — stay visible.

- categories.ts: `cognition` domain conflated true knowledge (document/
  investigation/runbook, 58 entities) with operational telemetry
  (execution/check/task/signal/approval/pattern/skill/classification/
  feedback, 300+ entities with their own Operations/Signals/Learning
  pages). Mapping the whole domain to Knowledge pulled in 245 execution
  entities fanning out from ~17 compute nodes via `targets` edges — the
  single biggest source of graph clutter. Knowledge now maps by type
  (document/investigation/runbook only); the rest of cognition is
  excluded from Knowledge Base browsing entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:17:13 +02:00
35c54ceef5 feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs
with a synthesized category taxonomy built from the ontology's finer-grained
`domain` field, since layer lumped unrelated entity types (an LXC and a DNS
record and a storage volume) into one bucket. Network and Fleet each span
two domains, so the table view now fans out per-domain fetches and merges,
while the graph view maps domain->category client-side. Also carries over
several detail-panel polish items (Tasks-not-raw-executions, slug URL
encoding, MultiSelectFilter) from earlier in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:53:15 +02:00
f8e03806aa feat(deploy): containerize the web UI as its own compose service
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
The SPA-from-binary split (0c0f35a) left `make deploy-ui` pointing at a
deploy path that was never actually wired up: scp to a "mac-mini" SSH
host that doesn't resolve from itself, a /var/www/oikos-ui/ that doesn't
exist, and `systemctl reload caddy` on a box with no Caddy installed at
all (not brew, not a container, nothing on 80/443).

Add a `web` service (compose/web/Dockerfile: node build -> caddy:2-alpine
static + SPA-fallback serving) to docker-compose.yml so the UI deploys
through the same push-to-main -> webhook -> docker compose build/up
pipeline the rest of the stack already uses, instead of a manual
scp/ssh step. Drop the broken `deploy-ui` Makefile target; `make ui`
stays as a local build sanity-check.

Update the reference Caddy config (compose/caddy/Caddyfile.oikos) to
reverse_proxy the new :8091 service instead of reading static files off
local disk, and fill in the <mac-mini-mesh-ip> placeholders with the
actual LAN IP (192.168.178.182 — the LXC and mac-mini subnets are
routed). This file is a reference only; the real caddy-conf repo change
is applied separately after review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-12 22:36:40 +02:00
94c94c0758 feat(web): merge Entities + Graph into a single Knowledge Base page
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Replaces the separate Entities/Graph nav items with one Knowledge Base
page that browses all entities as either a table or a force-graph,
scoped by ontology layer (Infrastructure/Governance/Cognition), with a
resizable browse/detail split instead of a slide-over sheet.

- New KnowledgeBase.svelte: layer tabs, view toggle, resizable
  browse/detail split (pattern from Chat.svelte's rail).
- EntityTable/EntityGraph extracted as presentational sub-components;
  their search/filter/root/depth toolbars live in the shared page
  toolbar (not the resizable pane) so they don't truncate when the
  divider is dragged narrow, and both views start flush with the
  detail pane for consistent height.
- EntityTable columns are sortable (slug/type/name/state/health).
- EntityDetailContent redesigned as a single-column list of
  collapsible sections (DetailSection.svelte), collapsed by default
  when empty; relation entries are clickable and select the entity in
  the browse pane + detail pane (and drill in-place in EntitySheet
  wherever it's used elsewhere in the app).
- api.ts: add layer filter to fetchEntities, add fetchEntityTypes for
  client-side graph layer scoping (the graph endpoint has no layer
  param).

Old hash routes (#/entities, #/graph) redirect to #/kb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-12 22:21:29 +02:00
d80a394b7f docs: fix plan/repo drift, retire dead Goose+Nomos and Caveman tooling
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Documentation and repo-hygiene pass following the client/server split:

Plan drift (audited all other active plans against current code):
- oikos-gaps-and-improvements.md: mark Section C and D.5 resolved (both
  described cmd/hermes, renamed to cmd/nomos with a real LLM loop since);
  refresh ~10 stale file:line citations; fix tool-count (33, not 28).
- liveness-drift-and-ux-cohesion.md: fix stale default-model claim (now
  deepseek-v4-pro since 2026-07-10) and "not yet deployed" status.
- nomos-agent-code-review.md: fix C1's citation (one unauthenticated route
  to nomos now, not two, after the client/server split).
- wails-desktop-app.md: record the production deploy outcome.

Repo structure: added missing directories to README/CONTRIBUTING layout
tables (checks/, tools/, cmd/webhook/, docs/operations/), fixed a broken
link, added ADR 0015 documenting the auth/CORS/client-split model (there
wasn't one despite CONTRIBUTING's own process requiring it), normalized
ADR 0013/0014's format drift, added an Authentication section to
AGENTS.md/CLIENTS.md (every example call was missing the now-required
bearer header).

Retired the Goose+Nomos workstation flow (bootstrap.sh --with-nomos,
tools/setup-nomos-soul.sh, .agents/operations/nomos-agent.md) and the
Caveman auto-install tooling (tools/setup-caveman.sh, tools/caveman/) —
both superseded by the production containerized Nomos agent, which has
never used either. Kept .agents/shared/caveman.md itself (the terse
writing-style convention agents still follow by reading it).

Deleted the orphaned legacy Python oikos/ directory — nothing imports it,
and bin/homelab (the CLI it was kept for) no longer exists in the repo.

Rewrote .agents/operations/agent-enrollment.md (365 -> ~110 lines) and
commands.md to match the current architecture instead of the retired
`homelab` CLI; migrated the still-true networking prerequisites (Netbird,
split-horizon DNS, SSH key distribution) into the knowledge base as a
runbook via upsert_knowledge rather than duplicating them in markdown.
Updated all 10 .agents/skills/ runbooks referencing the dead CLI with
their real MCP tool / REST API equivalents, or flagged them as needing
verification where no equivalent is confirmed yet.

Two real bugs found and fixed, not just docs:
- The tools/setup-*.sh auto-setup glob was tools/*.setup.sh in THREE
  places (tools/post-pull.sh, bootstrap.sh, and internal/httpapi/impl.go's
  GetClientContext handler) since the mechanism's introduction on
  2026-06-02 — never matched any real filename, so no client has ever
  picked up an auto-setup script via git-pull or the context-poller sync.
  Fixed all three; the Go server-side fix is the one that actually matters
  since it's what the current context-poller mechanism depends on.
- bootstrap.sh removed dead vestigial --gitea-token/--gitea-user flags
  (parsed, never consumed) left over from an earlier clone-based model.

Also flagged, not fixed (documented as an open gap in
client-enrollment/SKILL.md): bootstrap.sh tells a freshly-enrolled client
to call POST /api/v1/clients/{slug}/activate to finish enrollment, but
that route doesn't exist in api/openapi.yaml — EnrollClient sets entities
to provisioning and nothing currently transitions them to active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:19:41 +02:00
0c0f35a3a9 feat(web): split SPA from oikos binary, require auth on every route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 15:49:42 +02:00
346eb2f144 chore: gitignore compiled binaries at root
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-12 12:40:55 +02:00
48827d5bb1 fix(deploy): add poller as fallback when Gitea webhook can't reach mac-mini
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Gitea (LXC 104, 192.168.8.x) can't reach mac-mini (192.168.178.182) due to
ALLOWED_HOST_LIST. As a fallback, a 2-minute launchd poller checks if
origin/main has new commits and runs deploy.sh if so.
2026-07-12 12:14:28 +02:00
56979ac4bd feat(deploy): add webhook receiver and launchd service for push-to-deploy
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- cmd/webhook/main.go: HMAC-validated webhook receiver on :9797
- launchd plist: keeps webhook running, PATH includes docker
- Makefile: 'make webhook' target
- Registered as Gitea webhook id 15 on dtoro/oikos

Fixes: auto-deploy was not wired on mac-mini after the consolidation
2026-07-12 12:12:01 +02:00
3157e6102a plans: Wails desktop app with client/server split
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 separates the SPA from the oikos binary (delete embed.go, add CORS,
make API base URL configurable, add auth interceptor, close dev-open gate).
Phase 1 builds a thin Wails v3 desktop wrapper — native window + tray +
notifications + auto-start + auto-update. SPA shared between browser and
desktop builds.
2026-07-12 11:49:27 +02:00
6807e353e3 feat(web): redesign Overview as the homepage with a living graph backdrop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Overview replaces Tasks as the default route: a centered new-task entry
with live fleet metrics, a scrollable/filterable task table, and an
ambient canvas rendering of the real entity graph (autonomous camera
drift + mouse parallax) behind it. Tasks sidebar entry is removed;
its status-bucketing logic moves to lib/tasks.ts for reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:07:36 +02:00
0ed171507f Merge remote-tracking branch 'origin/main'
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-12 09:18:23 +02:00
e3cbaee534 fix(knowledge): populate hit id in search results
Search hits and entity-knowledge hits never selected an id column, so
every KnowledgeHit.Id defaulted to the zero UUID. The frontend's keyed
{#each results as hit (hit.id)} then had all-duplicate keys, which
silently broke Svelte 5's if-block branch swap for the results panel —
search would set searched=true (Clear button appeared) but the view
never switched away from "Recently learned". Select e.id in both
queries and key the each block on hit.slug (guaranteed unique) instead.
2026-07-12 09:16:03 +02:00
de126daf43 feat(web): fold Events/Agent/Audit into EntityDetail; tag agent_activity with entity_id
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Events, Agent, and Audit were standalone read-only pages that never
cross-referenced the entity they related to. Fold them into EntityDetail
as entity-scoped cards (Agent activity, Audit trail) alongside the
existing Signals/Executions/Knowledge cards, and give the Signals card
real Ack/Mute/Resolve actions. Signals stays a standalone page since
it's the only one with cross-entity triage value (badge count, actions).

Also fixes the underlying reason those new cards would've stayed empty:
agent_activity rows were never tagged with entity_id at insert time
(cmd/nomos/store.go, internal/mcp/server.go), even though the column
and the API filter both support it. Added a best-effort resolver that
checks common tool-arg keys (target, entity_slug, slug, ...) against
the entities table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 08:38:19 +02:00
c8b479d565 docs: close out task-completion safety net plan; fix stale relative links
Fixes 1-3 deployed and verified live: fresh trivial Q&A sessions now reach
done immediately, and a goal-bearing session that stalled was correctly
nudged by the idle sweep. Fix 4 (backfill) was replaced with deletion after
the operator's call — verified against the DB first that zero knowledge
notes were linked to or written by any of the 53 removed sessions, so
nothing was lost. Documents the pagination gap in listSessions (hardcoded
LIMIT 50, no total count) that hid 6 of those sessions from the original
audit.

Also fixes relative links in this plan and in the UI-review plan that broke
when both moved from plans/ to plans/done/ (one directory level deeper).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 00:14:15 +02:00
075ff93792 docs: mark task-completion safety net fixes 1-3 in progress
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:11:56 +02:00
3b9c75fa3f fix(agent): task completion safety net — stop tasks sticking at Running
Implements fixes 1-3 of plans/2026-07-11-task-completion-safety-net.md.
Confirmed live that 50/50 production sessions never reached a terminal
status because the model almost never calls complete_task, even for
trivial single-tool Q&A turns SOUL.md explicitly calls out as needing it.

- Inline safety net (agent.go): a session that never called set_goal never
  framed itself as a structured task, so its first plain-text turn-end IS
  the task ending — auto-complete it there instead of leaving status stuck
  at its creation default forever.
- Idle sweep (continue.go, new completion_nudges column): goal-bearing
  sessions that stall get one nudge, then auto-close with outcome=partial
  if the nudge goes unanswered, mirroring the pattern resumeSession already
  uses for a different stuck-session failure mode.

Fix 4 (backfill of the 50 already-stuck live sessions) is deliberately
separate — deferred until this is deployed and verified live, per the
plan's implementation order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:11:24 +02:00
e3850f6820 docs: task completion safety net — every live task stuck Running
Traced during UI-review verification: 50/50 live sessions are stuck
active/planning, never done/failed. Root cause confirmed against the
running DB — set_goal called once, propose_plan and complete_task
called zero times across all 50 sessions. The model consistently
skips the terminal complete_task call despite SOUL.md explicitly
instructing it to, especially for trivial single-tool Q&A turns.
Plan proposes an inline safety net for the common case plus an idle
sweep for structured goal/plan sessions that stall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:01:35 +02:00
fb4c76ba82 fix(ui): implement UI review findings — a11y, IA, and consistency fixes
Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail,
Entities row), case-sensitive entity filter, two competing entity-detail
navigation patterns (standardize on EntitySheet), non-clickable Overview KPI
cards, a bare button bypassing the shared Button component, inconsistent
blur-only vs live filtering, and an unenforced sanitization assumption on
search snippet HTML (now using the already-present dompurify dependency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:52:59 +02:00
b72267bd72 docs: nomos agent code review — mark all fixes done except deferred C1
Every finding from the review is now implemented and verified live:
A1 (3919ec3), B1+B2 (c5ffaec), A3 (926969a), D1-D3 (76f7630), A2 (c390164),
B3 (6d4f6de), F1 (11c18e8). C1 (nomos gateway has no authentication) remains
explicitly deferred per operator instruction. Kept in plans/ (not moved to
done/) since C1 is still open, matching how other partially-complete plans
in this index are tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:32:29 +02:00
11c18e8956 perf(agent): cache the MCP tool list per client (F1)
Fix F1 of plans/2026-07-11-nomos-agent-code-review.md, the last item.
buildTools called listToolsFull (a tools/list MCP round-trip) at the start of
EVERY chat turn, including every auto-continuation resume — the tool list is
static for the lifetime of one MCP connection, changing only when the api
process re-registers tools (a restart, which this client already detects and
reacts to via reconnectLocked). Re-fetching it every single turn was
avoidable network+parsing work on the hot path.

mcpClient now caches the parsed tool list after its first fetch, guarded by
its own mutex (kept separate from the request-serializing mu so a cache
check never contends with an in-flight doRequest call). reconnectLocked
clears the cache — an api restart may have changed what's registered, so a
stale cache would be wrong, not just slow. fleetSnapshot's get_health_summary
call is deliberately left uncached — it's meant to be "as of now."

Since each session gets its own client (the per-session pool from the
concurrency work), this caches per-task-conversation rather than globally: a
task's FIRST turn still pays the round-trip, every turn after reuses the
cached list — which is exactly the case that mattered (long-running,
heavily-autonomous tasks with many auto-continuation resumes).

Verified live via the api's request log: a brand-new session's first turn
made 3 MCP calls (initialize, tools/list, get_health_summary); a second turn
on the SAME session made exactly 1 (only get_health_summary) — tools/list
correctly skipped.

This completes the implementation order in
plans/2026-07-11-nomos-agent-code-review.md — every A/B/D/E/F finding from
the review (excluding C1, explicitly deferred per operator instruction) is
now fixed, tested, and verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:30:07 +02:00
6d4f6de676 fix(agent): mark a task failed when its resume permanently gives up (B3)
Fix B3 of plans/2026-07-11-nomos-agent-code-review.md. resumeSession's retry
loop (used by both auto-continuation and panel-answered questions) already
retried once on a transient LLM failure, but if BOTH attempts came back
empty/erroring, the code just logged and returned — the task was left at
whatever status it already had (typically 'executing' or 'awaiting_input')
with no outcome, no operator-visible signal beyond an inert error line
buried in the transcript, and no way to tell a genuinely stuck task apart
from one quietly still working.

On permanent failure, now calls store.completeTask(outcome='failure', a
summary built from the error) so the task board reflects reality instead of
showing a task that looks perpetually in-progress. Uses context.Background()
for that write, matching resumeSession's own persistence pattern, since the
context that led to the failure may itself be in a bad state. This doesn't
prevent the operator from continuing to work the task via a fresh chat
message afterward — it only replaces silent hanging with a real status.

A full live induction of a permanent LLM outage would require breaking the
model/API-key config for the whole nomos container — too invasive for this
fix's priority. Verified instead that the new branch stays correctly dormant
on the happy path: ran a real ask_operator → panel-answer → resume cycle
end-to-end and confirmed the task landed at status='executing' with no
outcome set, proving the failure-handling code doesn't false-positive on a
normal successful resume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:25:21 +02:00
c3901641d1 fix(agent): bound conversation history replayed to the LLM (A2)
Fix A2 of plans/2026-07-11-nomos-agent-code-review.md. chatWith replayed a
session's ENTIRE message history into the LLM's context on EVERY turn, no
windowing, no token budget — confirmed against a documented production case
(a single turn with 70 tool calls, messages up to 106KB). Every subsequent
turn of a long-running or heavily-autonomous task re-sent that ever-growing
history in full — a real cost/latency/eventual-context-limit risk for
exactly the tasks this system runs longest (many auto-continuation cycles).

Design call (flagged in the review as needing one before implementation):
a fixed-size window for LLM replay specifically, not the UI's own transcript
view. Simplest option that still keeps roughly the current task's working
context; a token-aware trim or LLM-summarize-on-drop are documented as
stretch options if 30 proves insufficient in practice.

- store.go: new getRecentMessages(ctx, sessionID, limit) — last `limit`
  messages in chronological order, plus whether older ones were omitted.
  getMessages (used by the UI's GET /sessions/{id}) is untouched and stays
  unbounded — the operator should still see a task's full history regardless
  of length; only what gets sent to the model is bounded.
- agent.go: chatWith uses getRecentMessages(sessionID, historyWindowSize=30)
  instead of the unbounded getMessages. When truncated, injects a system
  note telling the model explicitly that older turns exist but aren't shown,
  so it checks upsert_knowledge/search_knowledge rather than assuming
  something wasn't done just because it isn't visible.

New cmd/nomos/store_test.go: real Postgres integration tests (mirroring
internal/db/integration_test.go's throwaway-database pattern, guarded by
OIKOS_TEST_DATABASE_URL). TestGetRecentMessages_Truncation is the direct
proof for this fix (35 messages → 30 returned, correctly ordered,
truncated=true; 5 messages → all 5, truncated=false) — both cases run
against a fully-migrated database, not mocked. Also added
TestProposePlan_AppendVsReplace, closing part of the review's test-coverage
finding (E) by permanently regression-testing the earlier append-vs-replace
plan fix (commit 5384499), which had only been verified manually until now.

Verified live: inflated a real session to 42 persisted messages via direct
SQL, then continued it with a real chat call — the turn proceeded normally
(multiple real tool-call iterations, no crash, no context-length error);
nomos stayed healthy throughout. A3's incremental persistence separately
confirmed to have caught the 7 real tool calls made before the client
connection was cut, cleanly closing out both fixes' interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:22:30 +02:00
76f76308cc fix(agent): D1-D3 cleanups — dead code, N+1 query, unvalidated outcome enum
Fixes D1-D3 of plans/2026-07-11-nomos-agent-code-review.md:

- D1: deleted isTaskTool — defined, never called (dispatch already checks
  handleTaskTool's own `handled` return value).
- D2: recordTouched issued one SELECT per entity slug found in a tool call's
  args; batched into one `WHERE slug = ANY($1)` query. Verified live: a turn
  naming three separate entities recorded involves edges for all three via
  the single batched lookup.
- D3: complete_task's outcome had a declared enum (success|failure|partial)
  in its tool schema but nothing validated it — an out-of-enum value (model
  typo or a weaker model not respecting the schema) silently persisted as-is,
  with only "failure" special-cased (anything else became status='done'
  regardless of what the value actually said). Now validated in
  handleTaskTool: empty defaults to "success" (unchanged), a recognized value
  passes through, anything else defaults to "partial" (safer than silently
  treating an unrecognized value as success) with a warning logged. Verified
  live: instructed the agent to call complete_task with outcome="unclear" —
  persisted as outcome='partial', not the literal invalid string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:13:48 +02:00
926969a03f fix(agent): live chat turns persist incrementally, survive client disconnect
Fix A3 of plans/2026-07-11-nomos-agent-code-review.md. handleChat only ever
saved the assistant message ONCE, after a.chat(...) returned, using
ctx := r.Context() for that write — the same context that cancels the instant
the client disconnects (Stop button, tab close, network blip). A disconnect
mid-turn meant the final save ran with an already-cancelled context and its
error was never checked: the entire turn's tool-call history was silently
lost from the persisted transcript, even though real work (executions
launched, knowledge written) had already happened server-side.

Brought handleChat in line with resumeSession's existing pattern
(continue.go): insert a placeholder assistant row immediately, update the
SAME row after every tool call. The key fix is WHICH context the writes use —
a new pctx := context.Background() for every DB write in this handler
(session creation/touch, the user message, question auto-close, the
placeholder + incremental updates, the title update), while ctx/r.Context()
still gates the agent's own work (a.chat) and the SSE writes exactly as
before — a disconnect still correctly stops the agent from doing further
work, it just no longer also erases what it already did.

Verified live: sent a message requiring 6 tool calls (get_entity/
get_relations/get_blast_radius on two targets) and force-killed the client
connection mid-stream with curl -m 12 (confirmed via exit code 28). Before
this fix the persisted transcript would show 0 tool-call entries; after,
all 12 raw tool_use/tool_result entries (6 calls × 2) were present and
correctly attributed by tool name — proving both that progress survives an
abort and that the incremental writes aren't corrupting the data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:10:04 +02:00
c5ffaec85b fix(agent): panic recovery on every background goroutine (B1+B2)
Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together,
since the right granularity for B1 in the auto-continuation worker turned
out to require B2's restructuring anyway (see below).

B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned
nothing before this — every explicitly-spawned goroutine (continuation
worker, resumed chat turns, async execution dispatch, the SSE listener, two
duplicate sshExec implementations' output-collector goroutines) crashed the
whole process on an unhandled panic, not just that one goroutine. More
consequential post-concurrency: more simultaneous unattended background work
means more surface area for one bad input to end every running task.

New internal/safego package: Go(label, fn) launches fn in a goroutine with a
recover-and-log wrapper. Applied at every bare `go` spawn site across the
three packages. Two sites needed bespoke handling instead of the generic
helper because their callers block on a channel and a silent recover would
just make them hang until timeout: sshExec's output-collector goroutine (two
near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go)
and httpapi's ListenAndServe goroutine — both now recover AND send a
synthetic error result so the waiting select unblocks immediately instead of
waiting out the full timeout.

httpapi's sseListener got extra treatment: its per-notification handling was
extracted into handleNotification with its own recover, so a panic decoding
ONE malformed pg_notify payload can't kill the listener goroutine for every
connected SSE client — the outer goroutine spawn only needs to guard the
connection setup/reconnect code around it.

B2: cmd/nomos/continue.go's processContinuations used to run every pending
continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the
ticker — meaning (a) task B's continuation waited for task A's full (up to
10-minute) resumed turn to finish first, undercutting this session's earlier
concurrency work on exactly the path autonomous tasks depend on most, and
(b) an unrecovered panic anywhere in that call chain didn't just crash the
process (B1) — even WITH B1's recovery wrapped only at the top-level worker
spawn, the panic would still unwind the ENTIRE ticker-loop goroutine,
silently ending auto-continuation for every task until nomos restarted.
Fixed by spawning each pending item via safego.Go individually: real
parallelism, and a bad item can now only ever take down its own goroutine.

Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete
proof — a deliberate panic inside Go() that would otherwise crash the whole
test binary; reaching the assertion after it IS the evidence recovery works.

Verified live against the rebuilt containers: full chat turn round-tripped
correctly (hostname lookup, 2 iterations, normal completion) — no regression
from threading safego.Go through the tool-dispatch/continuation paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:05:19 +02:00
3919ec37d7 fix(agent): word-boundary matching + contracted negatives in chat-assent
Fix A1 of plans/2026-07-11-nomos-agent-code-review.md. isAssent and
isTypedConfirmation used a space-padded word-boundary check for negation
words but a bare strings.Contains for assent/confirm words — confirmed live
via test probes: isAssent("...maybe yesterday's logs...") returned true
("yes" matched inside "yesterday"), and isTypedConfirmation("I haven't
confirmed anything yet") returned true ("confirm" matched inside "confirmed",
and "haven't" wasn't in negationWords — only "don't"/"do not" were).
isTypedConfirmation is the sole gate for DESTRUCTIVE actions, so the second
case meant a message merely stating something hadn't been confirmed could
read as an explicit confirmation.

- Replaced the ad-hoc space-padding/prefix-check negation logic with proper
  tokenization (wordTokenRe) + containsPhrase, matching WHOLE tokens/phrases
  only — never a mid-word substring. Handles curly apostrophes too (a
  pre-existing gap: the old straight-quote-only check would have missed
  "don't" typed with a smart quote).
- Added contracted negatives (haven't, hasn't, isn't, wasn't, aren't, can't,
  cannot, won't, wouldn't, shouldn't, didn't, doesn't) to negationWords.
  Deliberately did NOT add a bare "not" — too broad, would false-negative
  ordinary assent like "go ahead, this is not risky".
- Added regression tests for both confirmed cases plus a couple of adjacent
  ones (eyesight/isn't, can't confirm) so a future change can't silently
  reintroduce either bug.

All existing assent/confirmation tests pass unchanged — this is a pure
robustness fix, not a behavior change for any previously-correct case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:51:57 +02:00
df393152f6 docs: nomos agent code review — gaps and improvement plan
Full read-through of cmd/nomos/ (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go). Findings, ranked:

- A1 (confirmed via runnable probe): isAssent/isTypedConfirmation use
  unpadded substring matching for assent/confirm words while negation uses
  word-boundary checks — "yes" matches inside "yesterday", "confirm" matches
  inside "confirmed" with no negation word covering contracted negatives
  ("haven't"). isTypedConfirmation gates DESTRUCTIVE actions specifically.
- A2: chatWith replays a session's ENTIRE message history every turn, no
  windowing/token budget — confirmed unbounded against a documented
  production case (70 tool calls, 106KB messages).
- A3: a live turn's tool-call history is lost entirely if the client
  disconnects mid-stream (single end-of-turn save using the same
  connection-tied, possibly-cancelled context) — resumeSession already has
  the fix pattern (incremental placeholder+update), handleChat doesn't use it.
- B1: zero recover() anywhere in cmd/nomos/internal/mcp/internal/httpapi —
  every explicitly-spawned goroutine (continuation worker, resumeSession,
  executeApprovedViaAPI, sse listeners) crashes the whole process on panic.
- B2: auto-continuation processes its batch sequentially, one full LLM turn
  at a time, undercutting this session's own concurrency work on exactly the
  path autonomous tasks depend on most.
- B3: no terminal state for a permanently-failed auto-continuation.
- C1: nomos's own gateway (port 8092, directly published + mesh-reachable)
  has ZERO authentication on any endpoint — chat, session read/delete,
  chat-assent approval of gated executions, all open to anyone on the LAN.
- D1-D3: dead code (isTaskTool unused), N+1 query in recordTouched, no
  validation on complete_task's outcome enum.
- E: zero automated tests for agent.go/store.go/main.go/tasks.go — including
  today's new safety-critical logic (session-scoped windows, mcpClientPool,
  proposePlan's append-vs-replace), verified only by live manual testing.
- F1: tool list + fleet snapshot re-fetched every turn (minor).

Prioritized implementation order in the doc: A1 → C1 → B1 → B2 → A3 → D1-3 →
A2 → B3/F1, tests landing alongside each fix rather than as a deferred pass.

Also archives the now-fully-shipped concurrent-task-execution plan to done/
(all 3 required fixes deployed this session; fix 4 explicitly deferred per
its own recommendation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:47:17 +02:00
a4ea542f3e fix(concurrency): per-session MCP client pool — removes cross-task tool-call blocking
Fix 3 of plans/2026-07-11-concurrent-task-execution.md, the throughput one.
nomos held exactly one *mcpClient for the whole process, shared by every
/chat goroutine. Its mutex was held for the full duration of each tool
round-trip, and `run` executes its SSH command SYNCHRONOUSLY inside that
round-trip (capped at up to 10 minutes) — so while Task A was mid-`run`,
every other task's tool calls, even a trivial get_entity, queued behind that
single lock. Tasks could think (LLM calls) in parallel but never act in
parallel.

The MCP server has no per-connection state to protect (newServer returns one
shared *mcp.Server instance whose handlers close only over the DB
connection pool, already safe for concurrent use) — the mutex existed purely
because the client reused one stateful transport session. So the fix doesn't
touch the server at all:

- New mcpClientPool (cmd/nomos/main.go): one *mcpClient per session id,
  created lazily (a real MCP initialize handshake) on first use and cached;
  session-less traffic (the ephemeral no-DB-store path, the structured
  /query endpoint) gets its own fixed, reused key instead of a fresh
  connection per request. Idle clients (20 min past last use — long enough
  to outlive a single slow `run`) are evicted on a 5-minute sweep ticker.
- agent.go: `client *mcpClient` → `clients *mcpClientPool`; every call site
  (buildTools, fleetSnapshot, the tool-dispatch loop) now resolves its own
  session's client via clients.get(sessionID) instead of reaching for one
  shared field. A task's own tool calls stay sequential (already true — the
  agent loop calls tools one at a time within a turn) but no longer block
  anyone else's.
- main.go: handleQuery takes the pool instead of a client (keyed "query", a
  fixed non-session slot); shutdown calls pool.closeAll().

Verified live against the deployed stack: fired a slow-but-ungated command
(`ping -c 15 127.0.0.1`, read-only per policy's allowlist, no approval
needed) as Task A, then — 2s into A's run — a trivial hostname lookup as
Task B, both through the real /chat endpoint. Task A's ping genuinely ran
~14.3s (confirmed via its own execution record and the agent's reported
output). Task B returned in 6s total, well before A finished — proving it
was never queued behind A's connection. Before this fix, B would have been
forced to wait out A's entire ~14.3s hold on the single shared client.

This completes plans/2026-07-11-concurrent-task-execution.md's required
scope — only the explicitly optional/deferred Fix 4 (a concurrency/cost cap,
pending real usage data) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:15:32 +02:00
6a8fb435ad fix(concurrency): per-session stream controllers, not one global slot
Closes the known gap flagged in the previous commit (9131559). A single
module-level `activeController` meant cancelStream()/newChat() always
aborted whichever stream was MOST RECENTLY STARTED, regardless of what the
operator was currently viewing: start Task A, switch to an already-loaded
Task B, click "New task" — the click's cancelStream() would silently abort
Task A's still-running turn, even though the operator was never looking at
it and never asked to cancel it.

- Replaced the single controller with activeControllers (Map<sessionID,
  AbortController>) plus pendingController for the brief pre-'session'-event
  window of a brand-new task. Registered immediately in sendMessage (keyed by
  the continuing session id right away, or held pending until the 'session'
  event assigns a new one) and cleaned up on completion.
- cancelStream() now looks up by $currentSession (falling back to
  pendingController when no session is assigned yet) — it can only ever
  touch the stream belonging to the view being left, never an unrelated
  background task's.
- newChat() unchanged in behavior (still calls cancelStream()), now correctly
  scoped through the above.

Verified live, reproducing the exact bug: started Task A (slow, 5 tool
calls), switched to an existing Task B, clicked "New task" while viewing
B — Task A was NOT aborted, ran to completion server-side with a full,
correct final summary (previously this exact sequence would have killed it).
Confirmed the positive path is unaffected: started a task, clicked Stop
while actively viewing it — input re-enabled, stream genuinely aborted
("BodyStreamBuffer was aborted"), turn stopped mid-flight as expected.

This closes out Fix 2's scope from
plans/2026-07-11-concurrent-task-execution.md; only Fix 3 (per-session MCP
client pool, throughput) and the optional Fix 4 (concurrency cap) remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:02:50 +02:00
9131559ebd fix(concurrency): guard chat.ts's stream callback against a stale session
Fix 2 of plans/2026-07-11-concurrent-task-execution.md. sendMessage's SSE
callback mutated the global messages/currentSession stores unconditionally,
assuming only one task's turn is ever in flight. It isn't — the backend runs
every /chat request as its own goroutine with no serialization. Switching to
a different task while a previous one was still streaming let that
background stream's later events (tool_use, text_delta, ..., and worst of
all 'done''s currentSession.set) get applied to whatever the operator is now
looking at: corrupting another task's transcript, or yanking the view back
to the one they left.

- Captures the session a stream belongs to (openedFor at call time, updated
  to the real id once the 'session' event assigns one) and checks
  $currentSession still matches before every messages/error/streaming
  mutation. The task keeps running server-side regardless — dropped events
  just mean the live view isn't watching it; navigating back re-hydrates via
  REST, same as already happens for auto-continuation.
- The 'session' event itself only claims currentSession if the operator
  hasn't already navigated elsewhere since the call started (comparing
  against openedFor, which is null for a brand-new task).
- loadSessionMessages/newChat now reset `streaming` to false unconditionally
  on navigation — needed so the new guard can't leave a DIFFERENT task's view
  stuck showing streaming=true (which would also silently stop startPolling's
  loop from ever applying updates, since it bails while $streaming is true).

Known residual gap, not fixed here (matches the plan's "contained fix, not a
rearchitecture" scope): activeController is still a single global slot, so
starting a new task while another is mid-stream, then clicking "New task"
again, aborts whichever stream that slot last pointed at rather than only
the one being left. A genuine multi-session controller/store is the
plan's deferred "stretch" fix, not required for correctness here.

Verified live: started Task A with a deliberately slow 4-tool-call turn,
switched to an existing Task B mid-stream — Task B's transcript stayed
correct with zero A-originated entries and the input was NOT stuck disabled.
Task A kept running and completed normally server-side (status=done, full
6-tool transcript, 5-entity graph); navigating back loaded its complete,
uncorrupted result via REST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:34:02 +02:00
9ef1ba3702 fix(concurrency): scope assent/destructive windows to session, not just agent
Fix 1 of plans/2026-07-11-concurrent-task-execution.md — the safety-critical
one. The assent window (and destructive window) were keyed purely by agent
id ("assent_window.agent:<uuid>"). With one agent:nomos entity serving every
concurrent task, this meant approving Task A's plan opened a window that ANY
concurrently-running task's config-mutation/destructive actions could also
ride, auto-executing without their own approval.

- store.go / agent.go: assentWindowActive/openAssentWindow and
  destructiveWindowActive/openDestructiveWindow/destructiveWindowKey all gain
  a sessionID parameter; keys become
  "assent_window.agent:<id>.session:<sessionID>" and
  "destructive_window.agent:<id>.target:<slug>.session:<sessionID>". Missing
  session id fails closed (no window) rather than falling back to the old
  agent-wide key.
- continue.go: the auto-continuation worker's window check moved from once-
  per-batch to once-per-pending-item, scoped to that item's own session —
  it was previously checking ONE agent-wide window for a batch that can span
  multiple tasks.
- agent.go tool-dispatch: injects `_session_id` into a COPY of the wire args
  sent to the MCP server (never into the args used for the emitted/logged/
  persisted tool call, and never part of any tool's declared InputSchema —
  invisible to the model) so the gating checks on the OTHER side of the
  process boundary know which task is asking.
- internal/mcp/server.go: assentWindowActive/destructiveWindowActive/
  classifyAndGate gain the same sessionID parameter, read from
  args["_session_id"] at the three call sites (request_execution's
  apt_upgrade/pct_create branches, and the shared classifyAndGate used by
  restart/pct_exec/systemctl/run).

Verified against the live stack with the exact scenario from the plan: opened
an assent window for session A only, then called `run` with an identical
config-mutation command for session A (window open) and session B (same
agent, no window). A auto-ran (execution status completed); B correctly
queued for approval (pending_approval) instead of bleeding through — proven
at both the MCP response text and the executions table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:27:29 +02:00
6932eb5eed docs: plan concurrent task execution; archive completed tasks plan
New plan grounds three concurrency issues found by tracing the actual code
(not assumed): the assent/destructive windows are keyed by agent id only
(no session dimension), so an approved plan in one task can auto-run
unapproved actions in a concurrently-running task; nomos shares one
mutex-guarded MCP client across all sessions, so a single slow `run` call
serializes every other task's tool calls behind it; and chat.ts's SSE
callback has no session guard, so switching tasks mid-stream lets the
backgrounded task's events corrupt whatever's now displayed. Proposes
session-scoping the windows (critical/first), a frontend stream guard
(contained/second), a per-session MCP client pool (throughput/third), and
an optional concurrency cap (deferred pending real usage data).

Also archives the goal-oriented-chat-control-panel plan to done/ — all 7
phases shipped and are live in production (SHA e30813a) — fixing its
internal relative links for the new depth and pointing forward to the new
concurrency plan as follow-up hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:14:35 +02:00
e30813a43d feat(tasks): make research-first / knowledge-write-back-last explicit steps
Closes the gap that made the knowledge loop optional/implicit: every
non-trivial task now has an EXPLICIT first plan step (research) and last
plan step (write back), not just background behavior the model might skip.

New MCP tools (the agent had no way to do these before — only REST endpoints
existed, unexposed to it):
- update_entity_attributes(slug, attributes): shallow-merge new/changed facts
  into an entity (an IP, a version, a discovered port) so a future task
  doesn't have to rediscover them from scratch. No approval required — this
  updates the knowledge graph, not live infra.
- create_relationship(source, target, type): record a discovered edge
  (depends-on, hosts, provides, ...). Idempotent, FK-validated against the
  ontology's relationship_types, no approval required.

SOUL.md: restructured the task loop so step 1 is explicitly "gather
knowledge, not just status" (get_entity_knowledge, search_knowledge,
get_relations, get_blast_radius, http_get) and the last step before
complete_task is explicitly "write back" (update_entity_attributes,
create_relationship, upsert_knowledge) — both called out as real plan
entries the operator should see in propose_plan, not silent side-work. This
is what prevents the graph drifting from reality and is the concrete
mechanism behind "tasks compound."

propose_plan's tool description reinforces the same first-step/last-step
convention at the call site.

Verified against the live stack: both tools registered and callable via MCP;
update_entity_attributes merged an attribute correctly; create_relationship
rejected an invalid type (FK violation, clear error) and succeeded with a
valid type+direction, confirmed idempotent (2 calls, 1 row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:14:02 +02:00
5384499903 fix(tasks): plan panel showed only the latest step, not the full plan
Root cause: proposePlan unconditionally deleted and replaced the whole
session_plan_steps list on every call. The model isn't strictly held to
"call propose_plan once with the full list" — nothing stopped it (and
production evidence + live testing showed it happening) from calling
propose_plan once per step as it worked. Each such call wiped every
already-completed step, so the operator only ever saw the model's latest
single step ("1/1") instead of the real, growing plan.

Fix, two layers:
- store.go: proposePlan now only does a destructive replace when no step
  has left 'pending' yet (a genuine pre-execution revision). Once any step
  has started, a new call APPENDS after the current max seq instead of
  wiping — so the panel accumulates the full history regardless of how the
  model chooses to call the tool. plan.proposed now carries `appended` so
  the frontend knows whether to replace or append.
- workspace.ts: plan.proposed handler respects `appended` (update vs set).
- tasks.go / SOUL.md: strengthened the propose_plan description and task-
  loop guidance to call it ONCE with the complete step list end-to-end,
  using update_plan_step (not re-calling propose_plan) to advance — fixing
  the root behavioral cause, with the store-side append as a safety net
  that holds even if the model still calls it incrementally.

Verified: forced the exact incremental-call pattern (propose_plan with 1
step, mark it running, propose_plan again with 1 more step) — the second
call appended at seq 2 instead of erasing seq 1, and its plan.proposed
event carried appended=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:07:10 +02:00
991e7d0900 feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)
Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 +02:00
413bf54daf feat(tasks): task board UI — chat window becomes Tasks + card grid
Reframes the chat surface as tasks:
- New Tasks.svelte: a card grid of tasks, each showing status (Running /
  Needs input / Done / Failed), the goal as title, the outcome summary, and
  relative time; filterable by status with live counts; delete on hover;
  "New task" and per-card click open the conversation.
- Board updates LIVE off the events stream (goal.set / task.status /
  question.*) via an explicit liveEvents.subscribe with a debounced refetch —
  scanning all events newer than the last seen, since entity.touched bursts
  bury task events below index 0.
- App shell: primary nav "Chat" → "Tasks" (board is now the home route),
  "New chat" → "New task", conversation header gets a Tasks / Conversation
  breadcrumb. Removed the superseded Sessions page.
- api.ts Session type carries the task fields (goal/status/outcome/summary).

Also fixes a pre-existing SSE bug that blocked ALL live updates app-wide:
writeSSE emitted `event: <type>`, which EventSource only delivers to
addEventListener(type) handlers — but stores/events.ts (and every page reading
liveEvents) consumes via onmessage, which never fires for named events. So the
live stream delivered nothing to the UI. Dropped the event-name line; the type
is already in the JSON payload, and new event types now need zero client
changes. SSE test still green (it parses data: lines).

Verified in the browser against the live stack: the board renders 50 tasks
with correct status buckets; a goal-driven task appears and flips to a Done
card with its summary in real time without a reload; Events page confirms the
stream now delivers to onmessage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:22:27 +02:00
014e5c74e0 feat(tasks): phase 5 — ask_operator (structured question, pause, resume)
The last backend piece: when the agent hits a decision only the operator can
make, it surfaces a structured question instead of guessing or stalling.

- ask_operator(prompt, why?, options?, context_entities?): nomos-local tool
  that records a session_questions row, moves the task to awaiting_input, emits
  question.raised, and ENDS the turn (the agent loop returns after it, so the
  agent can't barrel past its own question). The prompt becomes the assistant's
  visible message so the question also shows inline in the transcript.
- Two resume paths, both close the question + emit question.answered + return
  the task to executing:
  - Panel: POST /sessions/{id}/questions/{qid}/answer → resumes the agent in the
    background with the answer injected (reusing the continuation machinery,
    refactored continueSession → resumeSession). Returns 202; the reply lands via
    message polling.
  - Chat reply: the next chat message on a task with an open question IS the
    answer — auto-closed in handleChat; the turn itself is the resume.

Verified end-to-end: forcing a decision paused the task at awaiting_input with
the structured question (prompt/why/options/entities); a panel answer resumed
the agent (it acknowledged host:strong and continued); a plain chat reply
auto-closed a second question. Cleanup + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:00:15 +02:00
be3ce761d4 feat(tasks): phase 4 — structured plan steps (set_goal/propose_plan/update_plan_step)
Gives a task a legible, live-advancing plan via three more nomos-local tools:

- set_goal(goal): records the task goal, status → planning, emits goal.set.
- propose_plan(steps[]): persists ordered steps (clean replace for v1 — a
  revision starts a new list), status → executing, emits plan.proposed with
  the persisted steps (id+seq) so the panel can address them.
- update_plan_step(seq, status, execution_id?): advances a step, stamping
  started_at/finished_at, emits plan.step.started/finished. Anchors the event
  to the step's target entity when it has one.

Belt-and-suspenders: when an execution linked to a step reaches a terminal
state, the api auto-closes the step (closePlanStepForExecution in
emitExecutionEvent) and emits plan.step.finished — so the board stays honest
even if the agent forgets to close a step it started.

Verified end-to-end: a goal-driven task fired goal.set → plan.proposed →
2× step.started/finished → task.status on the SSE stream; both steps persisted
done with start/finish timestamps; status progressed planning→executing→done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:27 +02:00
532310bb4b feat(tasks): phase 3 — close the knowledge loop (complete_task + retrieval)
Adds the compounding knowledge loop the task model is built around:

- complete_task(outcome, summary): a nomos-LOCAL, session-scoped tool (the
  shared MCP server has no session id). Introduces the local-tool mechanism —
  buildTools appends task tools, the agent loop routes them to handleTaskTool
  instead of the MCP client. Sets the task's terminal status/outcome/summary,
  mirrors it onto the task entity, and emits task.status.
- Knowledge → task linkage: after a successful upsert_knowledge in a task,
  nomos links the note to the task entity (documents) and emits
  knowledge.recorded, so the task's outcome view shows what it learned. The
  note's about-link to the involved entity (written by upsert_knowledge) is the
  retrieval path future tasks use.
- SOUL: every chat is a task loop — retrieve prior knowledge FIRST
  (get_entity_knowledge on the target), plan, execute, record learnings, then
  complete_task. Scales down for trivial read-only tasks.
- deleteSession now cleans up the task entity, its relationships, and its
  task-scoped events (was orphaning them); the knowledge doc itself and its
  about-links survive, as knowledge should outlive the task.

Verified end-to-end: a task recorded a note and completed; task.status +
knowledge.recorded hit the SSE stream; status=done/outcome=success persisted;
the note linked to both lxc:caddy (retrieval) and the task; a future
get_entity_knowledge(lxc:caddy) surfaces it; delete cleaned edges+events (0/0/0)
while the knowledge survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:43:29 +02:00
3dba2e550a feat(tasks): phase 2 — entity.touched events + task→entity involves edges
As the agent runs a task, record which entities each tool call references:
write an idempotent task —involves→ entity relationship and publish one
entity.touched event per entity (correlation_id = session, data {slug,tool}).
Extracted from tool ARGS only — never results — so a bulk fleet query can't
drag every entity into the task graph; bulk/no-slug tools stay silent.

Emitted from the nomos agent loop rather than the shared MCP wrapper, which
has no session id. The involves edges make a task's graph neighborhood its
involved-entity set (queryable via get_relations) — the substrate for the
knowledge loop; the events are the live pulse the context panel consumes in
phase 6.

Verified end-to-end on the local stack: a chat referencing lxc:caddy/lxc:gitea
produced entity.touched on the browser SSE stream with slug+tool+correlation,
and exactly one involves edge per entity despite repeated touches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:35:27 +02:00
72e9fe534e feat(tasks): phase 1 — elevate chat session to a task (schema + task entity)
Migration 018 adds goal/status/outcome/summary/entity_id to agent_sessions
and creates session_plan_steps + session_questions. Registers a 'task'
entity type and an 'involves' (task→entity) relationship in the ontology
so each session anchors its knowledge and involved-entity edges on the
existing relationships graph.

nomos createSession now mints a task:<session-id> entity (type task) and
links it via agent_sessions.entity_id — best-effort so chat never blocks on
it. listSessions/GET /sessions surface the new task fields.

No behaviour change yet; this is the data foundation for the task board and
live context panel. Verified end-to-end against the local stack: migration
applied, ontology ingested (60 types/47 rels), a new session mints a linked
task entity and the API returns status/goal/entity_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:28 +02:00
eed6e3b1c5 docs: task-centric chat plan (goal → single-approval → autonomous + knowledge loop)
Reframes the chat surface as a board of tasks: each task carries a goal,
a plan approved once, a lifecycle status, an outcome, and a knowledge
loop that links learnings to the involved entities (and the task entity
itself) via relationships so future tasks compound. Supersedes the
sidebar-only framing and the free-form chat portion of the control-room
web UI plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:17:36 +02:00
ef5a92269b docs: reconcile plans/ status against actual code state
Audited all 10 active plan docs against the codebase (not just commit
titles). 5 were fully shipped and stale-tagged "Planned"/"In Progress" —
moved to done/ with verification notes. The other 4 got corrected
Planned→In Progress status plus concrete remaining-gap notes so the next
pass doesn't re-derive what's already done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 11:42:26 +02:00
196 changed files with 18584 additions and 4037 deletions

View File

@@ -1,19 +1,27 @@
# NOMOS.md — Agent persona for homelab clients # NOMOS.md — Agent persona for homelab clients
This file is the canonical agent persona for **all** AI agents running on This file is the canonical agent persona for AI agents running on machines
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency in the **hubris** homelab (Claude Code, Codex, or similar). It prescribes
conventions, and the source-of-truth hierarchy. behaviour, token-efficiency conventions, and the source-of-truth hierarchy.
The *production* Nomos agent (`cmd/nomos`, the containerized MCP client
gateway everyone actually talks to) uses a separate, code-adjacent persona —
`nomos/SOUL.md`, baked into its Docker image at build time
(`compose/nomos/Dockerfile`). This file is unrelated to that one; it's for
AI coding agents working *on* a homelab client machine, not the Nomos
service itself.
## Source of truth ## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of The homelab-context repo at `/opt/homelab-context/` is the single source of
truth for: truth for:
- Fleet topology (`inventory.yaml`, `inventory.yaml`) - Fleet topology (`inventory.yaml`)
- Service endpoints and credentials (via `homelab secret`)
- Agent behaviour and conventions - Agent behaviour and conventions
- Everything in this file - Everything in this file
When in doubt, check `/opt/homelab-context/` first. When in doubt, check `/opt/homelab-context/` first, or query the Oikos API/MCP
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
authoritative at runtime.
## Runbooks — load, don't rediscover ## Runbooks — load, don't rediscover
@@ -28,69 +36,9 @@ wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
operating model these runbooks execute inside (OODA loop, risk classes, operating model these runbooks execute inside (OODA loop, risk classes,
approval flow, ontology). approval flow, ontology).
## Agent type — how this file gets loaded ## Token efficiency
| Agent | Loading mechanism | Apply [caveman.md](shared/caveman.md) — terse, fragment-heavy chat responses
|-------|------------------| (not committed documentation). There's no separate tool to install for
| **Nomos** | `tools/setup-nomos-soul.sh` (auto-setup) → provisions `~/.nomos/SOUL.md` from this file | this; it's a response-style convention any agent follows by reading the
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints``/opt/homelab-context/NOMOS.md` | file.
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
homelab-context repo instead. Changes propagate to all clients on the next
sync (`sudo homelab sync`).
---
## Token efficiency (caveman skill)
All homelab agents use the **Caveman + RTK** token optimization approach from
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
### Before running any CLI command, ask:
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
- `~/bin/caveman_wrapper.sh git-status` — compact git status
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
```
rtk <command>
```
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
operations.
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
raw SSH/shell — they're already token-optimized.
### Templates
Caveman templates live at `~/templates/`:
- `git_status.txt` — compact git status format
- `git_log.txt` — compact git log format
- `lint_results.txt` — compact ESLint format
- `test_results.txt` — compact vitest/jest format
### When to skip caveman/rtk
- Interactive commands (editors, prompts) — let human-readable output pass
- Commands with no output — skip entirely
- When you need the exact raw output for post-processing
### Verification
```bash
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
```
## Important note for Nomos agents
If you are reading this as a Nomos agent, your SOUL.md was auto-provisioned
by `tools/setup-nomos-soul.sh`. This file is the canonical original — you
can verify the content matches or re-provision by running:
bash /opt/homelab-context/tools/setup-nomos-soul.sh

View File

@@ -10,6 +10,7 @@ see [CONTRIBUTING.md](../../CONTRIBUTING.md) for a human-friendly version.
cmd/oikos/main.go Entry point. Subcommands: api, scheduler, notifier, migrate, cmd/oikos/main.go Entry point. Subcommands: api, scheduler, notifier, migrate,
seed, export, secret, all seed, export, secret, all
cmd/nomos/main.go Nomos MCP client gateway (standalone binary, formerly Hermes) cmd/nomos/main.go Nomos MCP client gateway (standalone binary, formerly Hermes)
cmd/webhook/main.go Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
internal/httpapi/gen/api.gen.go. Strict server in impl.go. internal/httpapi/gen/api.gen.go. Strict server in impl.go.
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.) internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
@@ -26,14 +27,19 @@ internal/domain/ Core types: entities, approvals, executions, signals
internal/ontology/ Type hierarchy validation, relationship checks internal/ontology/ Type hierarchy validation, relationship checks
internal/knowledge/ Knowledge YAML seed ingestion internal/knowledge/ Knowledge YAML seed ingestion
internal/config/ Config loading from env vars internal/config/ Config loading from env vars
web/ Control-room SPA (Svelte 5) — standalone static build, not
embedded in the oikos binary (plans/2026-07-12-wails-desktop-app.md)
api/openapi.yaml REST API contract. Source of truth for endpoints. api/openapi.yaml REST API contract. Source of truth for endpoints.
api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/ api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations. migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
seeds/ Bootstrap YAML. ontology.yaml, inventory.yaml, policy.yaml, seeds/ Bootstrap YAML. ontology.yaml, inventory.yaml, policy.yaml,
knowledge.yaml. Regenerated from DB via oikos export. knowledge.yaml. Regenerated from DB via oikos export.
compose/ Dockerfiles. oikos/ (multi-stage), nomos/ (distroless). compose/ Dockerfiles. oikos/ (2-stage, Go only — SPA is built/deployed
separately), nomos/ (distroless).
Caddy config at compose/caddy/Caddyfile.oikos. Caddy config at compose/caddy/Caddyfile.oikos.
scripts/ Deploy, rollback, watchdog, verification, cutover checklist. scripts/ Deploy, rollback, watchdog, verification, cutover checklist.
checks/ Host health-check scripts run over SSH by the scheduler.
tools/ Client auto-setup scripts (checks).
nomos/ Nomos config.yaml, SOUL.md, skills. nomos/ Nomos config.yaml, SOUL.md, skills.
.agents/ Agent instruction files, domains, shared conventions, skills. .agents/ Agent instruction files, domains, shared conventions, skills.
plans/ Design documents. active/ + done/. plans/ Design documents. active/ + done/.

View File

@@ -1,365 +1,120 @@
# Agent enrollment — bootstrap a client into the homelab context system # Agent enrollment — operational notes
This walks through enrolling a new machine (workstation, LXC, or VM) so it **For the actual enrollment flow, see [CLIENTS.md](../../CLIENTS.md#enrollment)
joins the cross-client context system: a `/opt/homelab-context/` clone of — it's the current, authoritative version.** This page used to duplicate
this repo that auto-syncs every 5 min, a per-client age key for SOPS that flow in more detail, describing a `homelab` CLI-based two-step
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config. ceremony (`homelab client add` reserves an inventory slot → client
bootstraps → operator finalizes the pubkey). That CLI and that flow don't
exist anymore — enrollment today is one shot: `bootstrap.sh` calls
`POST /api/v1/clients/enroll` directly and gets back an age keypair +
Infisical identity in the same response. What's left here is the handful
of things that are still true and weren't already covered elsewhere.
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment? ## Prerequisites
> See [nomos-agent.md](nomos-agent.md). It uses the same `bootstrap.sh`
> with an additional `--with-nomos` flag.
Architecture in [project_homelab_context_plan](https://… memory link); the
operational reference is here.
## Prerequisites the client must satisfy
| Requirement | Why | How to check | | Requirement | Why | How to check |
| --- | --- | --- | | --- | --- | --- |
| Hostname matches an entry in `inventory.yaml` | The bootstrap looks up `hosts/$(hostname).yaml`. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) | | Hostname matches an entry in `inventory.yaml` | `EnrollClient` looks up the entity by slug derived from hostname; it must exist in `planned`/`provisioning` state. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` | | OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` | | On the mesh (Netbird) **or** on the LAN | enrollment validates mesh IP against expected subnets | `netbird status` |
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below | | `curl`, `jq`, `age`, `python3` | bootstrap preflight (`bootstrap.sh:100`) — auto-installed on Fedora/RHEL/Debian/Ubuntu/macOS if missing | `command -v curl jq age python3` |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) | | Can resolve `*.hubris.network` | bootstrap calls the Oikos API and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` |
### Hostname mismatch is the most common bootstrap failure ### Hostname mismatch is the most common bootstrap failure
If the bootstrap exits with `no hosts/<name>.yaml in the repo`, the If the entity for your hostname doesn't exist yet (in `planned` or
hostname doesn't match any inventory entry. Two fixes: `provisioning` state), enrollment 4xxs. Two fixes:
- **Rename the host**: `sudo hostnamectl set-hostname <inventory-name>` - **Rename the host** to match an existing planned entity:
(Linux) or System Preferences → Sharing (macOS), then re-run. `sudo hostnamectl set-hostname <inventory-name>` (Linux) or System
- **Rename the inventory entry**: edit `inventory.yaml` on hubris, Preferences → Sharing (macOS), then re-run.
update `inventory.yaml`, push. The next sync (≤5 min) propagates. - **Add/rename the inventory entry**: edit `seeds/inventory.yaml`, ingest
via `oikos seed` (or the equivalent MCP/API entity-creation path), then
re-run bootstrap.
### Getting onto Netbird ### Networking prerequisites (Netbird, DNS, SSH key distribution)
Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`. Migrated to a runbook in the knowledge base — query
`search_knowledge("netbird mesh dns")` or `get_entity_knowledge`, or ask
**Path B — interactive OIDC (default; recommended):** Nomos. Covers: getting onto the Netbird mesh (interactive OIDC vs.
setup-key), why OIDC login can fail from off-mesh, split-horizon DNS
The new client runs bootstrap straight from a fresh OS. Bootstrap installs netbird (apt/dnf/brew based on the OS), then runs `netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`. A device-code URL prints inline. The operator opens it (in a browser logged into Authentik), goes through identification → password → consent, and the CLI returns `Connected`. Bootstrap then proceeds with the rest of preflight. options, and distributing a new workstation's SSH pubkey across the fleet
via `ssh/deploy-keys.sh`.
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../../archive/knowledge/containers/106-auth-outpost.md). Only needed once per account.
**Path A — setup-key (headless/scripted onboarding):**
Useful for headless servers (no browser at all) or unattended cloud-init bootstraps.
1. From an already-enrolled machine, log into the dashboard at `https://netbird.hubris.network/`.
2. **Setup Keys** → Create → set reusable + expiry → copy.
3. On the new client (after installing netbird, OR let bootstrap install it and skip its `netbird up` driver):
```bash
sudo netbird up --setup-key <KEY> \
--management-url https://netbird.hubris.network \
--ssh-jwt-cache-ttl 86400
```
**Why we can't OIDC-login from the public internet (still open as a follow-up):**
`auth.hubris.network` resolves publicly to the VPS (`82.165.190.79`), but Traefik on the VPS doesn't currently route that hostname — only `netbird.hubris.network` is exposed. A brand-new client *off the mesh* hitting `auth.hubris.network` directly gets a Traefik default 404. In practice, Path B works fine because the operator's BROWSER (which clicks the device-code URL) is usually on a network that can reach Authentik through the public IONOS IP via some path. But "fresh laptop in a coffee shop with no prior session anywhere" still gets stuck. Future-session fix: add a Traefik route on the VPS forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124.
### DNS prerequisite
`*.hubris.network` resolves via the split-horizon dnsmasq on LXC 124
([dns.md](../../archive/knowledge/infrastructure/dns.md)) for LAN clients, **but only if the
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
workstations don't by default. Options:
- **LAN client**: set DNS to 192.168.8.180 (per-interface or
`/etc/resolv.conf`).
- **Off-LAN workstation on Netbird**: configure Netbird DNS forwarder to
point `*.hubris.network` at LXC 124.
- **Hack-fix anywhere**: append to `/etc/hosts`:
```
192.168.8.175 mcp.hubris.network secrets.hubris.network
192.168.8.175 git.hubris.network
```
(192.168.8.175 = caddy on LXC 121, terminates all `*.hubris.network`.)
If DNS isn't an option at all, override the URLs at bootstrap time:
```bash
sudo HOMELAB_GITEA_TOKEN=... \
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git \
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
bash /tmp/bootstrap.sh --with-mcp
```
## Install dependencies
Bootstrap auto-installs missing prerequisites (`git`, `python3` + PyYAML, `age`, `sops`, `netbird`) on Fedora/RHEL/Debian/Ubuntu/macOS — no manual `apt`/`dnf`/`brew` needed before running it. The only thing you must have on hand BEFORE the `curl ... | sudo bash` line is `curl` itself (used to pipe the script).
Manual install is still possible (e.g. for air-gapped or unusual platforms); the per-OS recipes are below for reference but optional.
<details>
<summary>Manual recipes (Fedora / Debian / macOS)</summary>
```bash
# Fedora / RHEL / Nobara
sudo dnf install -y git python3-pyyaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# Debian / Ubuntu
sudo apt update && sudo apt install -y git python3-yaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# macOS
brew install git age sops
pip3 install pyyaml # if `python3 -c "import yaml"` fails
```
</details>
## Run the bootstrap
You need a Gitea read-only personal access token for the initial clone
(the in-cluster shared PAT is encrypted at `secrets/gitea-readonly-pat.yaml`
but a new client can't decrypt it before bootstrap — chicken-and-egg).
Ask the operator (or generate in Gitea: Settings → Applications → Generate
New Token → scope `read:repository`).
```bash
TOKEN=... # your Gitea PAT, scope read:repository
# Fetch bootstrap.sh from gitea (HTTPS uses split-DNS → caddy).
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
# Run it.
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp
```
Flags:
| Flag | Effect |
| --- | --- |
| `--with-mcp` | Merges the homelab MCP server into `~/.claude/.mcp.json` of the invoking user |
| `--no-secrets` | Skips age-key issuance (use when bringing up the first hosts before secrets-issuance exists) |
| `--dry-run` | Prints actions without executing |
The bootstrap is idempotent: re-running on an enrolled client just
verifies state, re-issues the age key only if it doesn't match the
inventory pubkey, and refreshes the sync timer + symlinks.
## Verify
```bash
homelab whoami # prints hosts/$(hostname).yaml
homelab list # shows the full topology
homelab status # ping + HTTP-check across hosts/services
homelab secret hello # decrypt the bootstrap-test secret
systemctl list-timers homelab-context-sync.timer
# next run within ≤5 min
```
For Claude Code: start a new session — the `homelab` MCP server appears
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
1 secrets-metadata).
## Post-bootstrap: SSH reachability
A new workstation must be reachable from other workstations and must be
able to reach every host by short hostname. Run these steps after the
bootstrap verify passes:
### 1. Enable SSH server
```bash
# macOS:
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
# Linux:
sudo systemctl enable --now sshd
```
### 2. Generate SSH key (if missing)
```bash
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
```
### 3. Publish pubkey to the repo
```bash
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
```
### 4. Deploy keys to all hosts
From any existing enrolled machine (hubris or another workstation):
```bash
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
```
This adds the new workstation's pubkey to hubris and every running LXC.
### 5. Generate SSH config
```bash
homelab ssh-config --install
```
Verify:
```bash
ssh hubris hostname # should return "hubris" without password
ssh gitea hostname # should return "gitea" without password
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
```
### 6. Add LAN IP to inventory (if on LAN)
If the workstation has a static or reserved LAN IP, add it to
`inventory.yaml`:
```yaml
hosts:
your-hostname:
lan_ip: 192.168.8.xxx
```
This gives it a primary LAN entry in the generated SSH config (faster
than the Netbird fallback). Commit + push, then:
```bash
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
```
## Claude Code permissions for fleet ops ## Claude Code permissions for fleet ops
By default Claude Code's auto-mode classifier asks for confirmation on every By default Claude Code's auto-mode classifier asks for confirmation on every
ssh into the mesh. The bootstrap already installs the ssh ControlMaster block ssh into the mesh. Pre-authorize the common fleet ssh pattern by adding to
so subsequent in-session sshes multiplex, but the *first* ssh of each session `~/.claude/settings.json`:
still gets classifier-evaluated. Pre-authorize the common fleet ssh patterns
by adding to `~/.claude/settings.json`:
```json ```json
{ {
"permissions": { "permissions": {
"defaultMode": "auto", "defaultMode": "auto",
"allow": [ "allow": [
"Bash(ssh -p 22022 *)", "Bash(ssh -p 22022 *)"
"Bash(homelab *)"
] ]
} }
} }
``` ```
The first rule covers any ssh to a mesh peer on the homelab netbird port; the This covers any ssh to a mesh peer on the homelab netbird port, scoped tight
second covers all `homelab` CLI invocations. Both are scoped tight enough that enough that the classifier doesn't gate it but loose enough to handle the
the classifier doesn't gate them but loose enough to handle the variety of variety of arguments.
arguments.
If you also want the netbird `--ssh-jwt-cache-ttl` flag rationale to be ## Open questions (not verified against current architecture — don't
visible to the classifier (it's not actually durable in 0.71.2, but the guess these from the old flow)
ControlMaster block is — see [runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md)
for context), drop a free-text rule into `autoMode.allow` describing the
authorization. Optional.
## Adding a new client to inventory The old two-step ceremony had answers for these; the current one-shot
`/api/v1/clients/enroll` flow may handle them differently and this hasn't
been re-verified:
If the hostname you want isn't yet in inventory, enrollment is a two-step - **Removing a client.** No current equivalent confirmed for the old
ceremony driven from an existing enrolled client (e.g. hubris). The `homelab client remove` (inventory removal + secret re-keying + key
`homelab` CLI handles steps 1 + 4; you provide steps 2 + 3. revocation). Likely maps to an entity lifecycle transition
(`.agents/skills/lifecycle-deprecate-node/` or `lifecycle-destroy-node/`)
```bash but those skills reference the same dead CLI and need their own check.
# 1. On hubris (or any existing client): add the inventory entry. - **Granting a secret to an already-enrolled client.** The old flow
homelab client add my-new-machine hand-edited `.sops.yaml` `creation_rules` + `sops updatekeys`. Given
# Prompts for kind, os, netbird FQDN, role. Commits + pushes. Infisical is now the primary secrets backend (SOPS is the DR fallback),
the current mechanism is probably Infisical-side, not a `.sops.yaml` edit
# 2. Join the new machine to Netbird (out-of-band, Netbird console / setup key). — not confirmed.
# 3. On the new machine: install deps + run bootstrap (above).
# Bootstrap calls /issue, receives a fresh age keypair, and prints the
# public key for the operator to commit back to inventory.
# 4. On hubris: finalize the age public key.
homelab client add my-new-machine --finalize-pubkey age1...
# Updates inventory.yaml hosts.my-new-machine.age_pubkey, regenerates
# inventory.yaml, commits + pushes. The 5-min sync propagates.
```
## Granting a secret to a new client
Adding a client doesn't grant them every secret. Recipients are explicit
per file via `.sops.yaml` glob rules. To grant a client access to (say)
`secrets/hello.yaml`:
1. Edit `.sops.yaml` at the repo root, add the client's `age_pubkey` to
the matching `creation_rules` block.
2. Re-key the existing ciphertext for the new recipient list:
```bash
sops updatekeys -y secrets/hello.yaml
```
3. Commit + push. On the next sync (≤5 min), the client can decrypt.
## Removing a client
```bash
# From any existing client:
homelab client remove my-old-machine
```
This:
1. Removes the inventory entry and `hosts/my-old-machine.yaml`.
2. Runs `sops updatekeys -y` against every file in `secrets/` (operator
must first remove the pubkey from `.sops.yaml` rules).
3. Calls `secrets-issuance` `/revoke` (admin-token-gated, on LXC 105) to
shred the key file and add the hostname to the denylist.
4. Commits + pushes.
The CLI prints a follow-up checklist that the operator must do manually:
- Revoke the peer in the Netbird console (denies future mesh access).
- **Rotate any credentials whose ciphertext the removed client already
has on disk.** The age key revocation only protects *future*
ciphertext; what's already been pulled is still decryptable until the
underlying credential changes.
- Optional: `homelab nuke my-old-machine` SSHes in, shreds
`/etc/age/key.txt`, removes `/opt/homelab-context`, disables sync.
## Troubleshooting ## Troubleshooting
| Symptom | Cause | Fix | | Symptom | Cause | Fix |
| --- | --- | --- | | --- | --- | --- |
| `no hosts/<hostname>.yaml in the repo` | Hostname doesn't match inventory entry | Rename either side (see above) | | Enrollment 404s / entity not found | Hostname doesn't match a `planned`/`provisioning` inventory entry | See "Hostname mismatch" above |
| `fatal: could not read Username for 'http://192.168.8.121:3000'` | bootstrap.sh's credentials file has wrong scheme | Fixed in commit `de6f8be`; pull latest `bootstrap.sh` | | `gnutls_handshake() failed` / TLS errors reaching `*.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS instead of the LAN/mesh path | See the networking runbook (split-horizon DNS section) |
| `gnutls_handshake() failed: TLS connection was non-properly terminated` cloning `git.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS IP | Configure split-DNS (LXC 180 / Netbird forwarder) or `/etc/hosts` override; or use `HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git` |
| `TLS/SSL connection has been closed (EOF)` connecting MCP | Same — `mcp.hubris.network` resolves to public VPS without this vhost | Same DNS fix |
| `Invalid Host header` from MCP server | FastMCP's DNS-rebinding protection (default whitelist is 127.0.0.1 only) | Fixed in commit `6848640`; pull latest `mcp/server.py` and redeploy |
| `python3-yaml` install fails on Fedora | Wrong package name | Use `python3-pyyaml` (Fedora) instead of `python3-yaml` (Debian) |
| `address already in use` for FastMCP | FastMCP defaults to 127.0.0.1:8000 | Fixed: server now sets `mcp.settings.host/port` from env (default `0.0.0.0:9810`) |
| `homelab: no age key at /etc/age/key.txt` even after bootstrap | `/etc/age` is 0700 root, so non-root users couldn't even stat the key file; existence check returned False under regular users | Fixed in commit `df6aca8`: the CLI re-execs `sops -d` via sudo when invoked as a non-root user. On older deployments, re-link the CLI with `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab` after the 5-min sync. |
| `homelab` CLI doesn't pick up repo updates | Pre-`02db…` bootstrap copied the binary instead of symlinking | One-time migration: `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab`. New bootstraps use the symlink, which auto-tracks the synced repo. |
| `homelab-context-sync.service` journal shows `fatal: could not read Username for 'https://git.hubris.network'` | Pre-fix bootstrap set the gitea credential helper via `git config --global`, which writes to `/root/.gitconfig` — invisible to the systemd timer's git process (no HOME set). | One-time migration: `sudo git config --system credential.helper "store --file=/etc/homelab-context/git-credentials"`. New bootstraps store the helper in `/etc/gitconfig` instead. |
| Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). | | Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). |
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../../archive/knowledge/containers/106-auth-outpost.md). |
| `netbird ssh` rejected with `JWT authentication failed: validate token (expected issuer=https://netbird.hubris.network/oauth2 ...)` | Peer's SSH JWT validator cached the OLD embedded-Dex issuer from before the 2026-05-21 Authentik migration. `systemctl restart netbird` and `netbird down/up` don't clear it — `client/internal/engine_ssh.go` bails out of `updateSSH()` if the SSH server is already running. | Full daemon bounce: `sudo systemctl stop netbird; sleep 3; sudo systemctl start netbird`. Verify with `grep -iE "issuer\|audience" /var/log/netbird/client.log \| tail`. Apply once per peer post-migration. |
| `netbird ssh` JWT passes but session closes with `user privilege check failed: user dtoro not found: unknown user dtoro` | netbird-ssh defaults the remote username to the LOCAL one (operator's laptop user). Hubris and LXCs only have `root`. | Always use explicit `root@` prefix manually: `netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted`. `homelab ssh <host>` does this automatically via `inventory.yaml`'s per-host `ssh.user` field (defaults to `root`). |
| `homelab ssh hubris` (or any host on the LAN) fails with `Connection refused` or hangs, despite mesh routing being up | Off-LAN networks (operator on a VPN / coffee shop / symmetric NAT) sometimes can't reach the LAN IP even with the netbird subnet route. | Newer homelab CLIs probe the LAN with a 1.5s TCP connect and transparently fall back to the netbird FQDN. If your `/usr/local/bin/homelab` is a symlink to `/opt/homelab-context/bin/homelab` it'll pick up the fix on the next 5-min context sync. Otherwise pull the latest from gitea. |
## Changelog ## Changelog
### 2026-06-02 — SSH reachability post-bootstrap steps ### 2026-07-12 — trimmed to current architecture
Added a new "Post-bootstrap: SSH reachability" section covering SSH key Removed everything describing the retired `homelab` CLI-based two-step
generation, pubkey publication, deployment to hosts, SSH config generation, enrollment ceremony (now: `CLIENTS.md`'s one-shot flow), the Nous-Hermes/
and LAN IP registration. New workstations enrolled via this doc will Goose cross-link (that whole flow was removed the same day), and CLI-syntax
automatically join the universal SSH mesh. troubleshooting rows with no current equivalent. Migrated the still-true
### 2026-05-31 — cross-link to nomos-agent.md Netbird/DNS/SSH-distribution content to a knowledge-base runbook rather
than duplicating it here. What's left is genuinely current or explicitly
flagged as unverified. Original ~365-line version is in git history
(`git log -- .agents/operations/agent-enrollment.md`) if any of the removed
detail turns out to still be needed.
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([nomos-agent.md](nomos-agent.md)) and noted it at the top of this page. The Nomos flow extends `bootstrap.sh` with `--with-nomos` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here. ### 2026-06-02 — SSH reachability post-bootstrap steps
Added a section covering SSH key generation, pubkey publication,
deployment to hosts, SSH config generation, and LAN IP registration. New
workstations enrolled via this doc automatically join the SSH mesh.
(Superseded 2026-07-12 — migrated to the networking runbook.)
### 2026-05-31 — cross-link to nomos-agent.md
Added a sibling page covering Nous-Hermes-on-Goose enrollment. (Removed
2026-07-12 along with the rest of that flow.)
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows ### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper. Added three rows to the troubleshooting table covering issues surfaced
during the netbird vanilla migration. (Migrated 2026-07-12 to the
networking runbook.)
### 2026-05-20 — initial page ### 2026-05-20 — initial page
Captures the enrollment flow validated during Phase 2 of the homelab Captures the enrollment flow validated during Phase 2 of the homelab

View File

@@ -53,34 +53,36 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
## Fleet apt operations ## Fleet apt operations
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC. **No current CLI equivalent.** `homelab apt-audit`/`apt-upgrade` (dpkg-state
audit, fanned-out apt upgrade with pre-upgrade snapshots) were part of the
| Command | What it does | retired Python `homelab` CLI and don't have a ported replacement — apt
| --- | --- | patching today is ad hoc `run` MCP tool calls per host, without the
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. | audit/snapshot/status wrapping this used to provide. If that wrapping is
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. | still wanted, it needs to be rebuilt (e.g. as a runbook driving `run`, or a
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. | new MCP tool) — see
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. | [runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md) for
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot``vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. | the dpkg-interrupted recovery procedure specifically.
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
## Oikos (agent OS layer) ## Oikos (agent OS layer)
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference: See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
section used to document is retired; the actual current interface is the
33 MCP tools cataloged in [AGENTS.md](../../AGENTS.md#3-the-mcp-server) plus
the REST API. Closest current equivalents for what used to live here:
| Command | What it does | | Old `homelab` command | Current equivalent |
| --- | --- | | --- | --- |
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history | | `homelab service <name> explain\|health\|docs\|log` | MCP `explain`, `get_service_status`, `tail_log`, `get_entity_knowledge` |
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius | | `homelab node <name> relations` | MCP `get_blast_radius` |
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command | | `homelab change preflight <service>` | MCP `preflight` |
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate | | `homelab signal list\|ack\|resolve\|mute` | MCP `get_signal_history`, or REST `POST /api/v1/signals/{id}/ack\|resolve\|mute` (the control-room UI's Signals page wraps these) |
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention | | `homelab approval request\|list\|reply\|check` | REST `GET/POST /api/v1/approvals*` (Matrix-delivered via the notifier, or the control-room UI's Operations page) |
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Nomos, or the Oikos Console's `/approvals` page) | | `homelab restart <service> --approval-id <id>` | MCP `run` (policy-gated — auto-executes if read-only/reversible_low, otherwise queues for the same Matrix/UI approval) |
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant | | `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`, not as a separate dry-run call |
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../../archive/oikos-cards/). There is no separately-deployed "Oikos Console" anymore — the control-room
SPA (`web/`) is the operator dashboard, served standalone (see
[plans/2026-07-12-wails-desktop-app.md](../../plans/2026-07-12-wails-desktop-app.md)).
## Related ## Related
- [Hubris host](../../archive/knowledge/hosts/hubris.md) - [Hubris host](../../archive/knowledge/hosts/hubris.md)

View File

@@ -1,210 +0,0 @@
# Nomos agent — LLM-powered terminal sessions on a homelab client
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
Llama variant) as a working terminal agent on a homelab client. Builds on top
of standard client enrollment (see [agent-enrollment.md](agent-enrollment.md))
— this page covers only the Hermes-specific additions.
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
- The chat loop, multi-turn history, and streaming
- The OpenRouter provider that routes to the configured LLM
- The built-in `developer` extension (shell + file editor — same surface Claude
Code has)
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
The persona is `/opt/homelab-context/NOMOS.md`, symlinked as Goose's global
`.goosehints` so it's injected into the system prompt on every session.
## Prerequisites
| Requirement | How |
| --- | --- |
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](agent-enrollment.md) |
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` |
## Onboarding flow
```bash
# 1. On hubris (or any enrolled client): reserve the inventory entry.
homelab client add new-machine
# 2. Join new-machine to Netbird (setup-key or OIDC).
# 3. On new-machine: bootstrap with --with-nomos.
TOKEN=... # gitea PAT, read:repository
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-nomos
# 4. Back on hubris: finalize the age pubkey AND grant the Nomos secret.
homelab client add new-machine \
--finalize-pubkey age1... \
--with-nomos
# 5. Wait ≤5 min for sync, then on new-machine:
nomos "what LXCs are running?"
```
The bootstrap `--with-nomos` flag does five things, all idempotent:
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
2. Symlinks `/opt/homelab-context/bin/nomos``/usr/local/bin/nomos`.
3. Symlinks `/opt/homelab-context/NOMOS.md``/root/NOMOS.md` (Linux) or
`/etc/NOMOS.md` (macOS) for `cat`-as-operator convenience.
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
extensions (preserves any keys the operator added by hand).
5. Symlinks `~/.config/goose/.goosehints` → NOMOS.md, so the persona is
injected as the system prompt on every session.
## Seeding the OpenRouter key
The first time anyone enrolls with `--with-nomos`, the encrypted file
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
existing recipient):
```bash
sops secrets/openrouter-api-key.yaml
# editor opens; replace api_key value with the real sk-or-... key, save, close.
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
git -C /opt/homelab-context push
```
Until this step happens, `nomos …` exits with `openrouter-api-key.yaml still
contains the placeholder`. Subsequent enrollees get the real key automatically
via `--with-nomos` (which adds them as a sops recipient on
`secrets/openrouter-api-key.yaml`).
## Granting the OpenRouter key to an already-enrolled host
If a host was enrolled without `--with-nomos` and you want to add it later:
```bash
# On hubris:
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-nomos
```
`--finalize-pubkey` is required by the existing flow even when the pubkey is
unchanged — it's also the trigger that runs the sops grant.
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
re-run; only the secret recipient list changed.
## Verifying
```bash
homelab whoami # standard enrollment OK
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
which goose && which nomos # binaries present
goose info -v # provider/model wiring sane
nomos "what LXCs are running?" # interactive Goose session
# Non-interactive smoke test:
echo "List the homelab MCP tools you have available" | nomos
```
## Configuration
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
```yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: deepseek/deepseek-v4-flash
GOOSE_MODE: smart_approve # asks before destructive tool calls
extensions:
developer:
type: builtin
bundled: true
enabled: true
name: developer
timeout: 300
homelab:
type: streamable_http
enabled: true
name: homelab
uri: https://mcp.hubris.network/mcp
timeout: 60
```
Override via env on a single bootstrap run:
```bash
HOMELAB_NOMOS_MODEL=nousresearch/hermes-3-llama-3.1-405b \
HOMELAB_NOMOS_MCP_URI=https://mcp.hubris.network/mcp \
sudo bash /tmp/bootstrap.sh --with-nomos
```
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
preserved across re-bootstraps — the merge only overwrites the keys it manages.
## Tool permissions
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
shell commands without prompting and asks for confirmation before destructive
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
every tool call, use `approve`. See
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `nomos: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` from hubris |
| `nomos: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
| Goose hangs on first `nomos` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
| `homelab` extension fails to connect / no MCP tools listed | MCP server upgraded in Go rewrite (`internal/mcp/server.go`, Streamable HTTP via official MCP SDK). Old FastMCP SSE transport is deprecated. | Run `docker compose --profile full up` on mac-mini, or wait for the production cutover from apps/105. |
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-nomos`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
## Cross-references
- [agent-enrollment.md](agent-enrollment.md) — base client onboarding the
Nomos flow assumes is done.
- [`NOMOS.md`](../NOMOS.md) — the persona the Nomos agent reads on every
session start (via `~/.config/goose/.goosehints`).
- [`bin/nomos`](../../bin/nomos) — the wrapper that decrypts the OpenRouter key
and execs `goose session`.
- [`bootstrap.sh`](../../bootstrap.sh) — the `--with-nomos` flag's install block.
## Follow-ups
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
(`"SSE transport is no longer supported - kept only for config file
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
server at `internal/mcp/server.go` uses Streamable HTTP (official MCP SDK). Until
that's changed, the `homelab` MCP extension in Goose will fail to connect.
The developer extension (shell + edit) covers most ops without it; this is
a polish item, not a blocker.
2. **Per-host OpenRouter keys** for billing attribution. Today all Nomos
hosts share one key.
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
directly — OpenRouter periodically rotates the underlying weights.
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
node. The wrapper, persona, and MCP wiring stay unchanged; only
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
Currently this auto-installs the Caveman npm package, wrapper scripts, and
compact output templates on all agent hosts (*token efficiency*).
## Changelog
### 2026-06-01 — caveman + post-pull auto-setup
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
Also created `tools/caveman/` with the wrapper script, JS renderer, and
templates — the canonical source for all agent hosts.
Captures the Nomos-on-Goose onboarding flow added in the same commit as
`bootstrap.sh --with-nomos`, `bin/nomos`, the sops rule for
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-nomos`
extension. MCP streamable_http migration is queued as follow-up #1.

View File

@@ -2,39 +2,46 @@
name: client-enrollment name: client-enrollment
risk_class: config_mutation risk_class: config_mutation
inputs: [hostname, kind, role] inputs: [hostname, kind, role]
verification: "homelab doctor (on the new client)" verification: "MCP whoami(hostname) shows the entity active"
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm] docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
--- ---
# Client enrollment # Client enrollment
Goal: bring a new host (workstation, LXC, VM) into inventory and the Goal: bring a new host (workstation, LXC, VM) into inventory and the
secrets model, with mesh membership only where it's actually needed. secrets model, with mesh membership only where it's actually needed. See
This wraps the existing `homelab client add` flow — see [CLIENTS.md](../../../CLIENTS.md#enrollment) for the actual current
[operations/agent-enrollment.md](../../operations/agent-enrollment.md) for flow and [operations/agent-enrollment.md](../../operations/agent-enrollment.md)
the full walkthrough; this runbook is the risk/lifecycle framing. for operational notes; this runbook is the risk/lifecycle framing.
1. On any enrolled client: `homelab client add <hostname>` — appends a 1. The entity must exist in `planned`/`provisioning` state before the new
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned` host can self-enroll — add a `hosts.<name>:` block to
`provisioning`, per [seeds/ontology.yaml](../../../seeds/ontology.yaml)), `seeds/inventory.yaml` and `oikos seed` to ingest it (lifecycle
commits + pushes. `planned``provisioning`, per
[seeds/ontology.yaml](../../../seeds/ontology.yaml)).
2. Netbird join is **optional, not a required step** — only needed for 2. Netbird join is **optional, not a required step** — only needed for
hosts that must be reachable off-LAN (workstations that roam, e.g. hosts that must be reachable off-LAN (workstations that roam, e.g.
`republic-laptop`, `mac-mini`). A node reachable on the household LAN `mac-mini`). A node reachable on the household LAN (192.168.8.0/24 —
(192.168.8.0/24 — most LXCs/VMs) doesn't need it: it's already most LXCs/VMs) doesn't need it. Skip for LAN-only nodes; do it
reachable directly, and off-LAN clients reach it too via hubris's (out-of-band, console or setup key) only for hosts that need
routed `192.168.8.0/24` Netbird network resource. Skip this step for independent off-LAN reachability.
LAN-only nodes; do it (out-of-band, console or setup key) only for 3. On the new host: run `bootstrap.sh`. This calls
hosts that need independent off-LAN reachability. `POST /api/v1/clients/enroll`, which validates the entity exists and
3. On the new host: run `bootstrap.sh` (add `--with-nomos` to also the mesh IP is in an expected subnet, then returns an age keypair and
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the Infisical machine identity in one response — provisions
sync timer, and prints an age pubkey. `/etc/age/key.txt`, `/etc/infisical/identity`, and the context poller.
4. Back on an enrolled client: `homelab client add <hostname> 4. **Known gap, confirmed 2026-07-12: `provisioning → active` has no
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared working path.** `EnrollClient` (`internal/httpapi/impl.go`) sets the
secrets, re-keys SOPS, commits + pushes. This is the entity's state to `provisioning`, never `active`. `bootstrap.sh` prints
`provisioning → active` transition. `POST /api/v1/clients/ws:$HNAME/activate` as the next step, but that
5. Verify: `homelab doctor` on the new client should show all checks route doesn't exist — `api/openapi.yaml` only has `/clients/enroll`,
green (clone, sync timer, age key, CLI symlink, MCP reachable). `/clients/{slug}/context`, `/clients/{slug}/secrets`. Until this is
fixed (add the route, or use the generic entity PATCH to flip `state`),
a freshly-enrolled client is stuck in `provisioning` — MCP `preflight`
and policy's `lifecycle_overrides` for `provisioning` still apply, but
nothing transitions it onward automatically.
5. Verify: MCP `whoami(hostname)` shows the entity in `active` state with
its peers and health.
Docs-update checklist: if the new host is an LXC/VM, add its narrative Docs-update checklist: if the new host is an LXC/VM, add its narrative
page under `containers/` or `vms/` and set `doc_page` in its inventory page under `containers/` or `vms/` and set `doc_page` in its inventory

View File

@@ -2,7 +2,7 @@
name: config-change-deploy name: config-change-deploy
risk_class: config_mutation risk_class: config_mutation
inputs: [service_name, change_description] inputs: [service_name, change_description]
verification: "curl -sf <service_url> (or homelab service <name> health)" verification: "curl -sf <service_url> (or MCP get_service_status)"
docs_update_checklist: [doc_page, changelog] docs_update_checklist: [doc_page, changelog]
--- ---
@@ -11,11 +11,10 @@ docs_update_checklist: [doc_page, changelog]
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
own repo) and get it live, safely. own repo) and get it live, safely.
1. `homelab change preflight <service>` — current health, the service's 1. MCP `preflight` — current health, the service's `config_repo`, its
`config_repo`, its risk class, and the verification command to run risk class, and the verification command to run after. If risk class
after. If risk class requires approval (`config_mutation` or requires approval (`config_mutation` or `destructive`), stop and get
`destructive`), stop and get operator sign-off before editing — see operator sign-off before editing — see `seeds/policy.yaml`.
`seeds/policy.yaml`.
2. Clone/pull the `config_repo` (never edit the backend's working tree 2. Clone/pull the `config_repo` (never edit the backend's working tree
directly — tracked configs change by commit + push, per directly — tracked configs change by commit + push, per
[OIKOS.md](../../OIKOS.md) conventions). [OIKOS.md](../../OIKOS.md) conventions).
@@ -24,10 +23,10 @@ own repo) and get it live, safely.
[infrastructure/auto-deploy.md](../../../archive/knowledge/infrastructure/auto-deploy.md) for [infrastructure/auto-deploy.md](../../../archive/knowledge/infrastructure/auto-deploy.md) for
the exact receiver/reload for this service). the exact receiver/reload for this service).
5. Run the preflight's verification command. If it fails, check 5. Run the preflight's verification command. If it fails, check
`homelab service <name> log` for the reload/restart error. MCP `tail_log` for the reload/restart error.
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling 6. No manual record-keeping step needed — mutations made through the API
(Week 3), this is automatic; until then, note the change and outcome (e.g. via the `run` MCP tool) are recorded automatically in the
in the relevant investigation/plan doc. `audit_log` table.
Docs-update checklist: update the service's `doc_page` if the change Docs-update checklist: update the service's `doc_page` if the change
alters its behavior, ingress route, or ownership; add a changelog entry alters its behavior, ingress route, or ownership; add a changelog entry

View File

@@ -10,22 +10,23 @@ docs_update_checklist: [investigations_entry]
Goal: understand what broke and why, before touching anything. Goal: understand what broke and why, before touching anything.
1. `homelab service <name> explain` (or `homelab node <name> relations` 1. MCP `explain` (or `get_blast_radius` if the affected entity is a
if the affected entity is a host) — get the blast radius and doc host) — get the blast radius and doc pointer first. Don't start
pointer first. Don't start pulling logs blind. pulling logs blind.
2. `homelab service <name> health` + `homelab service <name> log` (or 2. MCP `get_service_status` + `tail_log` for the affected service.
MCP `get_service_status` / `tail_log`) for the affected service.
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`, 3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
`authentik`, or the backend host itself)? `homelab node <name> `authentik`, or the backend host itself)? MCP `get_blast_radius`
relations` shows "affected by" — check those first. shows "affected by" — check those first.
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade 4. If the symptom looks like a dpkg/upgrade interaction, see
interaction. [runbook-dpkg-interrupted](../runbook-dpkg-interrupted/SKILL.md) —
there's no fleet-wide apt-audit tool anymore, check the host directly.
5. Check the change ledger for recent mutations to the affected entity 5. Check the change ledger for recent mutations to the affected entity
or anything upstream of it: `homelab service <name> history` (once or anything upstream of it: MCP `get_change_history` or `get_audit_trail`.
populated) or grep `ledger/*.jsonl`. 6. Write findings via MCP `upsert_knowledge` (`kind: investigation`) —
6. Write findings to a new `knowledge/sources/investigations/<date>-<slug>.md` — symptom, symptom, timeline, root cause, fix applied, prevention, `about` set to
timeline, root cause, fix applied, prevention. This is the durable the affected entity's slug. The DB is the durable record now, not a
record; don't rely on chat history. markdown file — `search_knowledge`/`get_entity_knowledge` read it back;
a chat message alone is forgotten.
Docs-update checklist: always create the investigation entry. If the Docs-update checklist: always create the investigation entry. If the
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`, root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,

View File

@@ -2,7 +2,7 @@
name: lifecycle-activate-node name: lifecycle-activate-node
risk_class: config_mutation risk_class: config_mutation
inputs: [node_name] inputs: [node_name]
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)" verification: "MCP get_service_status (if it hosts a service); MCP whoami (if it's a client)"
docs_update_checklist: [doc_page_complete] docs_update_checklist: [doc_page_complete]
transition: "provisioning -> active" transition: "provisioning -> active"
--- ---
@@ -14,23 +14,22 @@ enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
ingress live if public, health check answering, doc page complete, ingress live if public, health check answering, doc page complete,
ledger entry. ledger entry.
1. If the node is a `homelab` client: finish enrollment per 1. If the node self-enrolls as a client: finish enrollment per
[client-enrollment.md](../client-enrollment/SKILL.md) (`--finalize-pubkey`, [CLIENTS.md](../../../CLIENTS.md#enrollment) (`bootstrap.sh`
mesh join, `homelab doctor` green). `/api/v1/clients/enroll`, mesh join, MCP `whoami` returns the entity).
2. If it hosts a public service: add the `services:` entry in 2. If it hosts a public service: add the `services:` entry in
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes — `seeds/inventory.yaml` (backend, url, doc_page, config_repo,
see the Week-1 service contract fields) and wire the Caddy route in risk_notes) and wire the Caddy route in `dtoro/caddy-conf`.
`dtoro/caddy-conf`. 3. Confirm the health check answers: MCP `get_service_status` or a
3. Confirm the health check answers: `homelab service <name> health` or direct `curl`.
a direct `curl`.
4. Flip `state: provisioning``state: active` (or delete the `state:` 4. Flip `state: provisioning``state: active` (or delete the `state:`
field — `active` is the default) in `inventory.yaml`. field — `active` is the default) in `seeds/inventory.yaml`, then
`oikos seed` to ingest.
5. Complete the doc page (stub → full narrative: role, specs, how it's 5. Complete the doc page (stub → full narrative: role, specs, how it's
configured, dependencies). configured, dependencies).
6. Record the activation: `oikos/ledger.py append host:<name> activate 6. No manual record-keeping step needed — the activation (via whatever
config_mutation --result ok` (or let the CLI wrapper do this once API call flipped the state) is recorded automatically in `audit_log`.
Week 3's runbook automation lands).
Regenerate derived data: `python3 mcp/build_host_files.py && python3 Regenerate: `oikos seed` re-ingests `seeds/inventory.yaml`; `oikos export`
inventory.yaml` so `inventory.yaml`, the topology diagram, and writes DB state back out to the YAML if you mutated via the API/MCP
the context card all reflect the new state. instead of editing the file directly.

View File

@@ -2,7 +2,7 @@
name: lifecycle-deprecate-node name: lifecycle-deprecate-node
risk_class: config_mutation risk_class: config_mutation
inputs: [node_name, replacement_node_or_reason] inputs: [node_name, replacement_node_or_reason]
verification: "homelab node <name> relations — 'affected by' must be empty before completing" verification: "MCP get_blast_radius — 'affected by' must be empty before completing"
docs_update_checklist: [doc_page_deprecation_note] docs_update_checklist: [doc_page_deprecation_note]
transition: "active -> deprecated" transition: "active -> deprecated"
--- ---
@@ -16,14 +16,14 @@ suggestion; `seeds/policy.yaml` `lifecycle_overrides.deprecated.refuse`
lists `new-inbound-edges` as refused going forward. lists `new-inbound-edges` as refused going forward.
1. Set `state: deprecated` on the node. 1. Set `state: deprecated` on the node.
2. `homelab node <name> relations` — read `affected_by`. Every entry 2. MCP `get_blast_radius` — read `affected_by`. Every entry there is
there is something still relying on this node. something still relying on this node.
3. Migrate or retire each dependent one at a time (point its `backend`/ 3. Migrate or retire each dependent one at a time (point its `backend`/
`config_repo`/ingress route elsewhere, or deprecate it too if it's `config_repo`/ingress route elsewhere, or deprecate it too if it's
being retired alongside). being retired alongside).
4. Re-run `homelab node <name> relations` after each dependent is moved. 4. Re-run MCP `get_blast_radius` after each dependent is moved. The
The transition to `destroyed` is only safe once `affected_by` is transition to `destroyed` is only safe once `affected_by` is empty —
empty — check this every time, don't assume from memory. check this every time, don't assume from memory.
5. Note the deprecation on the doc page: reason, replacement (if any), 5. Note the deprecation on the doc page: reason, replacement (if any),
date. date.

View File

@@ -2,7 +2,7 @@
name: lifecycle-destroy-node name: lifecycle-destroy-node
risk_class: destructive risk_class: destructive
inputs: [node_name] inputs: [node_name]
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it" verification: "MCP get_blast_radius returns unknown-entity; pct list on the backend no longer shows it"
docs_update_checklist: [archaeology_entry, containers_index_update] docs_update_checklist: [archaeology_entry, containers_index_update]
transition: "deprecated -> destroyed" transition: "deprecated -> destroyed"
--- ---
@@ -15,28 +15,35 @@ recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
ledger entry. ledger entry.
1. Confirm the node is `deprecated` with zero `affected_by` edges 1. Confirm the node is `deprecated` with zero `affected_by` edges
(`homelab node <name> relations`) — do not skip this even if the (MCP `get_blast_radius`) — do not skip this even if the deprecation
deprecation runbook was followed recently; state can drift. runbook was followed recently; state can drift.
2. If it's an enrolled client: `homelab client remove <name>` — revokes 2. **If it's an enrolled client: no current tool for revoking its age key /
the age key, re-keys SOPS, removes the inventory entry. This is removing its Infisical identity.** The old `homelab client remove`
already destructive-class and confirmed in the CLI. (age key revocation + SOPS re-key + inventory removal, all one
destructive-class CLI call) is retired along with the rest of that CLI
and hasn't been re-verified against the current enrollment
architecture (`POST /api/v1/clients/enroll` + Infisical machine
identities) — see the "Open questions" section in
[agent-enrollment.md](../../operations/agent-enrollment.md). Until
that's confirmed, treat key/identity revocation as a manual step:
at minimum remove the client's `age_pubkey` from any SOPS recipient
lists and rotate credentials whose ciphertext it already decrypted.
3. Remove any ingress route (Caddy config repo) and DNS record still 3. Remove any ingress route (Caddy config repo) and DNS record still
pointing at it. pointing at it.
4. Verify backups of anything on it are retained per policy before the 4. Verify backups of anything on it are retained per policy before the
disk goes away (see `backs-up-to`). disk goes away (see `backs-up-to`).
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`). 5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
6. Move the `hosts.<name>:` block (if any inventory remnant survives 6. Update the entity's `state` to `destroyed` in `seeds/inventory.yaml`
`client remove`, e.g. infra-only LXCs with no age key) into (or move it to an `archaeology:`-style section if the schema still has
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date, one) — `pve_id`, `destroyed` date, `reason` — then `oikos seed` to
`reason`. Add a row to `containers/index.md` "Recently destroyed" ingest. Add a row to `containers/index.md` "Recently destroyed" table
table (kept for human-readable browsing alongside the structured (kept for human-readable browsing alongside the structured data).
data). 7. No manual ledger step — mutations through the API are recorded
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`. automatically in the `audit_log` table (MCP `get_audit_trail`,
8. Regenerate: `python3 mcp/build_host_files.py && python3 `get_change_history`). The old `oikos/ledger.py append` was retired
inventory.yaml` — the node drops out of `inventory.yaml` and when this became automatic.
appears in the topology doc's archaeology table.
If the destroy fails partway (e.g. secrets revoked but pct destroy If the destroy fails partway (e.g. secrets not fully revoked but pct
errors), do not re-run step 2 — `client remove` is not idempotent destroy errors), finish the remaining steps manually and note the
against a second revocation attempt on the issuance server. Finish the partial state in an investigation (MCP `upsert_knowledge`,
remaining steps manually and note the partial state in an investigation. `kind: investigation`).

View File

@@ -2,7 +2,7 @@
name: lifecycle-migrate-node name: lifecycle-migrate-node
risk_class: config_mutation risk_class: config_mutation
inputs: [node_name, source_host, target_host] inputs: [node_name, source_host, target_host]
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service" verification: "MCP get_blast_radius (re-check blast radius); MCP get_service_status for every hosted service"
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip] docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
transition: "active -> migrating -> active" transition: "active -> migrating -> active"
--- ---
@@ -10,12 +10,12 @@ transition: "active -> migrating -> active"
# Lifecycle: migrate a node # Lifecycle: migrate a node
Modeled on the strong Phase 1+2 migration Modeled on the strong Phase 1+2 migration
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)). ([archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
Requires (ontology): preflight + backup-verified before migrating; Requires (ontology): preflight + backup-verified before migrating;
post-verify + Caddy backends checked + mounts checked + docs updated post-verify + Caddy backends checked + mounts checked + docs updated
before returning to `active`. before returning to `active`.
1. `homelab change preflight <every service the node hosts>` — capture 1. MCP `preflight` for every service the node hosts — capture
current health as a baseline. current health as a baseline.
2. Verify backups are current for anything with data at rest on the 2. Verify backups are current for anything with data at rest on the
node (see `backs-up-to` edges once populated). node (see `backs-up-to` edges once populated).
@@ -29,11 +29,12 @@ before returning to `active`.
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's 6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
backend IP for each affected service matches the new `lan_ip` backend IP for each affected service matches the new `lan_ip`
(automatic in Week 3's drift detector), confirm mounts still resolve. (automatic in Week 3's drift detector), confirm mounts still resolve.
7. `homelab service <name> health` for every service the node hosts. 7. MCP `get_service_status` for every service the node hosts.
8. Set `state: active`. Add a migration note to the node's doc page 8. Set `state: active`. Add a migration note to the node's doc page
(old host/IP → new, date, phase reference) — this repo's convention (old host/IP → new, date, phase reference) — this repo's convention
for every past migration (see `archive/knowledge/containers/101-jellyfin.md`, for every past migration (see `archive/knowledge/containers/101-jellyfin.md`,
`containers/129-house.md`). `containers/129-house.md`).
Regenerate: `python3 mcp/build_host_files.py && python3 Regenerate: `oikos seed` (re-ingests `seeds/inventory.yaml` into the DB —
inventory.yaml`. the DB is authoritative at runtime, the YAML is the source of truth
on disk).

View File

@@ -21,10 +21,12 @@ chosen, doc page stub.
`qm create`), choosing the storage pool deliberately — record it as `qm create`), choosing the storage pool deliberately — record it as
the `storage:` field once populated (Week 1 schema; not yet backfilled the `storage:` field once populated (Week 1 schema; not yet backfilled
for existing nodes). for existing nodes).
2. Add the inventory entry: `homelab client add <name>` for anything that 2. Add the inventory entry: a `hosts.<name>:` block in
will run the `homelab` CLI, or a direct `hosts.<name>:` block with `seeds/inventory.yaml` with `state: provisioning`, `kind`, `host`,
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for `pve_id`, `lan_ip`, then `oikos seed` to ingest it. For anything that
infra-only LXCs that won't self-enroll. will self-enroll as a client afterward (see
[CLIENTS.md](../../../CLIENTS.md#enrollment)), the entity must exist in
`planned`/`provisioning` state before `bootstrap.sh` runs there.
3. Stub the doc page (`containers/<pve_id>-<name>.md` or 3. Stub the doc page (`containers/<pve_id>-<name>.md` or
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X" `vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
is enough to satisfy the transition requirement. is enough to satisfy the transition requirement.

View File

@@ -18,7 +18,7 @@ summarised into targets and fixed costs.
- `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude. - `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude.
- The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name, - The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name,
Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …). Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …).
- API token: `homelab secret yuvomi-api-token` (decrypts on any enrolled client). - API token: `yuvomi-api-token`, via Infisical (primary) or `oikos secret` (SOPS fallback).
- Direct API base: `https://house.hubris.network/api/v1` - Direct API base: `https://house.hubris.network/api/v1`
--- ---

View File

@@ -13,7 +13,8 @@ has packages that are **unpacked but not configured**. Symptoms:
manually run 'dpkg --configure -a' to correct the problem.` manually run 'dpkg --configure -a' to correct the problem.`
- `dpkg --audit` lists packages with header - `dpkg --audit` lists packages with header
`The following packages have been unpacked but not yet configured.` `The following packages have been unpacked but not yet configured.`
- `homelab apt-audit` shows `DPKG: DIRTY(N)` for the host. - `dpkg --audit` on the host directly shows unpacked-not-configured packages
(there's no fleet-wide audit tool anymore — check per-host).
The system is still running the **old** binaries (still in memory), but the The system is still running the **old** binaries (still in memory), but the
**new** binaries are unpacked and waiting for their postinst to run. Two **new** binaries are unpacked and waiting for their postinst to run. Two
@@ -33,19 +34,20 @@ config dirs, capabilities, etc.). The system might not come back up cleanly.
## Path A — target is still reachable over ssh (preferred) ## Path A — target is still reachable over ssh (preferred)
``` ```
homelab ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade' ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
``` ```
Or for an LXC by name: Or for an LXC by name (via the MCP `run` tool, or directly on the Proxmox
host):
``` ```
homelab pct <lxc> exec -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade' pct exec <lxc> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
``` ```
When that returns, confirm: When that returns, confirm:
``` ```
homelab apt-audit --target <host> ssh <host> -- dpkg --audit
``` ```
Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally
@@ -89,10 +91,11 @@ DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
## Prevention ## Prevention
The `homelab apt-upgrade` wrapper launches apt inside a `systemd-run --collect` The old `homelab apt-upgrade` wrapper (retired along with the rest of the
unit on the target, so it survives ssh teardown — the failure mode that put `homelab` CLI) used to launch apt inside a `systemd-run --collect` unit on
hubris into this state in the first place is no longer reachable through the the target so it survived ssh teardown — that's the failure mode that put
standard tool. If you absolutely need to run apt manually over ssh, wrap it: hubris into this state in the first place. There's no fleet-wide wrapper
anymore; if you run apt manually over ssh, wrap it yourself the same way:
``` ```
ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade' ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade'

View File

@@ -2,7 +2,7 @@
name: service-health-check name: service-health-check
risk_class: read_only risk_class: read_only
inputs: [service_name] inputs: [service_name]
verification: "homelab service <name> health" verification: "MCP get_service_status"
docs_update_checklist: [] docs_update_checklist: []
--- ---
@@ -10,15 +10,15 @@ docs_update_checklist: []
Goal: determine whether a service is actually healthy, without ad-hoc SSH. Goal: determine whether a service is actually healthy, without ad-hoc SSH.
1. `homelab service <name> explain` — read the context card: backend, 1. MCP `explain` — read the context card: backend, blast radius, doc
blast radius, doc pointer, risk notes. pointer, risk notes.
2. `homelab service <name> health` — live health probe (HTTP code against 2. MCP `get_service_status` — live health probe (HTTP code against the
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this service's `url`/`endpoint`); the scheduler also probes on its own
reads a cached snapshot by default; pass `--live` to force a fresh probe. interval, so this may reflect a recent cached result, not necessarily
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the a fresh one.
last 200 lines. 3. If unhealthy, `tail_log` for the last 200 lines.
4. Cross-check blast radius: `homelab node <name> relations` — is this 4. Cross-check blast radius: MCP `get_blast_radius` — is this entity's
entity's own backend host healthy? A downstream failure (e.g. `strong` own backend host healthy? A downstream failure (e.g. a Proxmox host
down) will show up here before the service's own logs explain anything. down) will show up here before the service's own logs explain anything.
5. If the fix is a restart: classify first (`seeds/policy.yaml` 5. If the fix is a restart: classify first (`seeds/policy.yaml`
`service-restart` is `reversible_low` unless the service has a `service-restart` is `reversible_low` unless the service has a

View File

@@ -36,7 +36,7 @@ For each session determine:
| Signature | Root cause | Fix | | Signature | Root cause | Fix |
|-----------|-----------|-----| |-----------|-----------|-----|
| Agent: "I can't run X — only supports Y" | Missing action in `request_execution` | Add action in `internal/mcp/server.go` | | Agent: "I can't run X" | Missing target or capability | Use `run` with shell command — there is no fixed action enum anymore |
| Agent: "No local knowledge on that" + no web tool | Missing `http_get` / web fetch MCP tool | Add MCP tool | | Agent: "No local knowledge on that" + no web tool | Missing `http_get` / web fetch MCP tool | Add MCP tool |
| Empty assistant bubble (text="", no tools) | Model returned blank completion | Retry + error surfacing | | Empty assistant bubble (text="", no tools) | Model returned blank completion | Retry + error surfacing |
| Non-English boilerplate refusal | Flash-tier model degradation | Response quality guard | | Non-English boilerplate refusal | Flash-tier model degradation | Response quality guard |
@@ -75,7 +75,7 @@ Session: {id[:8]} — "{title[:60]}"
- `cmd/nomos/agent.go` — agent loop, tool building, response guards - `cmd/nomos/agent.go` — agent loop, tool building, response guards
- `cmd/nomos/store.go` — session + message persistence - `cmd/nomos/store.go` — session + message persistence
- `internal/mcp/server.go` — all tool implementations including `request_execution` - `internal/mcp/server.go` — all tool implementations (`run`, `list_lxcs`, …)
- `web/src/lib/components/ToolCallGroup.svelte` — tool result display - `web/src/lib/components/ToolCallGroup.svelte` — tool result display
- `nomos/SOUL.md` — agent persona and tool selection rules - `nomos/SOUL.md` — agent persona and tool selection rules
- `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings - `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings

View File

@@ -0,0 +1,70 @@
name: Desktop App
on:
push:
branches:
- main
tags:
- 'desktop-*'
- 'v[0-9]+.[0-9]+.[0-9]*'
jobs:
build:
name: Build Linux (amd64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
working-directory: web
- run: npm run build
working-directory: web
- run: |
rm -rf cmd/desktop/frontend/dist
mkdir -p cmd/desktop/frontend/dist
cp -r web/dist/* cmd/desktop/frontend/dist/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- run: CGO_ENABLED=1 go build -o build/bin/Oikos .
working-directory: cmd/desktop
- run: |
cd cmd/desktop/build/bin
tar czf oikos-desktop-linux-amd64.tar.gz Oikos
sha256sum oikos-desktop-linux-amd64.tar.gz > oikos-desktop-linux-amd64.tar.gz.sha256
- uses: actions/upload-artifact@v4
with:
name: oikos-desktop-linux-amd64
path: |
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz.sha256
release:
name: Attach to Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
name: oikos-desktop-linux-amd64
- uses: https://gitea.com/actions/release-action@v1
with:
files: |
oikos-desktop-linux-amd64.tar.gz
oikos-desktop-linux-amd64.tar.gz.sha256
api_key: ${{ secrets.GITEA_TOKEN }}

26
.gitignore vendored
View File

@@ -2,25 +2,25 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# Regenerated every scheduler run; ephemeral health-probe cache.
oikos/state.json
# Compiled binaries (Go rewrite — bin/oikos, bin/nomos) # Compiled binaries (Go rewrite — bin/oikos, bin/nomos)
bin/oikos bin/oikos
bin/nomos bin/nomos
oikos/oikos oikos
webhook
# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite).
# oikos/ kernel files are still imported by bin/homelab for operational CLI
# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp).
# Remove oikos/* when bin/homelab is ported to Go.
backups/ backups/
.env .env
.infisical-credentials .infisical-credentials
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the # Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build,
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a # deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md
# fresh checkout before the UI is built. # 0.1), so the output dir is just a build artifact.
web/dist/* web/dist/
!web/dist/.gitkeep
web/node_modules/ web/node_modules/
# Wails desktop app — frontend copy for embedding
cmd/desktop/frontend/dist/
cmd/desktop/build/
cmd/desktop/Oikos
desktop
/eval

115
AGENTS.md
View File

@@ -34,7 +34,8 @@ Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
That file tells you your role, your peers, what's mounted, and what services That file tells you your role, your peers, what's mounted, and what services
you host. If it does not exist, this client was not enrolled — stop and tell you host. If it does not exist, this client was not enrolled — stop and tell
the operator to run `homelab client add <hostname>` from an existing client. the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
(the entity needs to exist in `planned`/`provisioning` state first).
## 2. The topology ## 2. The topology
@@ -49,17 +50,24 @@ the operator to run `homelab client add <hostname>` from an existing client.
## 3. The MCP server ## 3. The MCP server
The homelab exposes a Model Context Protocol server with structured tools. The homelab exposes a Model Context Protocol server with structured tools.
Endpoint: `https://mcp.hubris.network/mcp`. Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
`Authorization: Bearer <token>` — the API has no unauthenticated path except
enrollment and `/healthz` (see "Authentication" below for where the token
comes from).
Available tools (21 total): Available tools (33 total):
Context — observe + orient: Context — observe + orient:
get_entity(slug), list_entities(type, limit, cursor), get_entity(slug), list_entities(type, limit, cursor),
get_relations(entity), get_blast_radius(entity), get_relations(entity), get_blast_radius(entity),
search_knowledge(query) — ILIKE search over documents, investigations, search_knowledge(query) — ILIKE search over documents, investigations,
runbooks in the knowledge_entities table runbooks in the knowledge_entities table
get_entity_knowledge(entity_slug) — every document, investigation, and
runbook linked to one entity, in one call
get_patterns(status, entity_type, action) — learned action patterns get_patterns(status, entity_type, action) — learned action patterns
get_skills(status) — available automation skills get_skills(status) — available automation skills
http_get(url) — fetch a public page/raw file (e.g. researching how to
deploy something before provisioning it); HTTP/HTTPS only, ~16KB cap
Management — live state: Management — live state:
get_service_status(service_slug) — systemctl is-active on target host get_service_status(service_slug) — systemctl is-active on target host
@@ -67,6 +75,8 @@ Available tools (21 total):
list_lxcs() — all LXC containers with ID, host, IP, health list_lxcs() — all LXC containers with ID, host, IP, health
get_lxc_state(lxc_slug) — pct status from Proxmox host get_lxc_state(lxc_slug) — pct status from Proxmox host
ping_service(service_slug) — HTTP reachability from entity_status ping_service(service_slug) — HTTP reachability from entity_status
list_my_secrets(caller_pubkey) — secrets accessible to this client by
age public key
Oikos — decisions: Oikos — decisions:
explain(service_slug) — compact context card (type, state, health, relations) explain(service_slug) — compact context card (type, state, health, relations)
@@ -84,11 +94,27 @@ Available tools (21 total):
get_trend(entity_id, days=7) — metric slope over time get_trend(entity_id, days=7) — metric slope over time
get_event_timeline(severity, entity_slug, limit) — recent events get_event_timeline(severity, entity_slug, limit) — recent events
Execution — the single mutation path: Knowledge — keep the graph current (none require approval; this updates
request_execution(target, action, params) — policy-gated. the knowledge graph, not live infrastructure):
reversible_low (restart, reload, pct_exec, apt audit) runs immediately; upsert_knowledge(title, content) — record what you learned after solving
config_mutation (systemctl enable/disable, apt upgrade) queues for operator a non-obvious problem; the only way anything persists past a session
approval via Matrix, then executes on ✅. update_entity_attributes(slug, attributes) — merge a discovered fact
(IP, version, port, ...) into an entity so a future task doesn't
rediscover it from scratch
create_relationship(source, target, type) — record a discovered edge
(depends-on, hosts, routes-to, ...) between two entities
Execution — mutating the live infrastructure:
run(target, command) — the general execution primitive. Run any shell
command against a host or LXC; every command is auto-classified —
read-only inspection runs immediately, anything state-changing needs
operator approval, and destructive patterns (rm -rf, dd, mkfs,
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
need approval regardless of what you declare. This is the ONLY
mutation tool — `request_execution` was retired 2026-07-14.
`run` — the general execution primitive. Run any shell
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the
route for those specific actions; policy-gated the same way `run` is.
get_execution_status(execution_id) — poll progress get_execution_status(execution_id) — poll progress
**When to prefer MCP over grepping the clone:** always for knowledge queries. **When to prefer MCP over grepping the clone:** always for knowledge queries.
@@ -97,7 +123,18 @@ the DB with entity links. `get_entity_knowledge("lxc:jellyfin")` returns documen
runbooks, and investigations in one call. Grep the clone only when MCP is runbooks, and investigations in one call. Grep the clone only when MCP is
unreachable. unreachable.
## 4. Knowledge conventions ## 4. Authentication
Every API/MCP route requires `Authorization: Bearer <token>` except
`POST /api/v1/clients/enroll` and `/healthz`. Enrollment (see
[CLIENTS.md](CLIENTS.md#enrollment)) does not currently issue a per-client
API/MCP bearer token — there is one shared
secret (`OIKOS_MCP_BEARER_TOKEN`, validated in `internal/httpapi/server.go`'s
`combinedAuth`); get it from the operator until per-client token issuance
exists. The SPA has its own flow instead: a first-launch Config screen that
stores a token in `localStorage` (see `web/src/pages/Config.svelte`).
## 5. Knowledge conventions
All narrative knowledge (documents, investigations, runbooks) lives in the DB All narrative knowledge (documents, investigations, runbooks) lives in the DB
(`knowledge_entities` table) and is seeded from `seeds/knowledge.yaml`. Agents (`knowledge_entities` table) and is seeded from `seeds/knowledge.yaml`. Agents
@@ -122,47 +159,63 @@ per the DB-as-source-of-truth plan.
running state, update the DB *in the same session* via the API. The `oikos export` running state, update the DB *in the same session* via the API. The `oikos export`
command regenerates `seeds/knowledge.yaml` for version control. command regenerates `seeds/knowledge.yaml` for version control.
## 5. Acting on the homelab ## 6. Acting on the homelab
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary - **Read state**: use MCP tools. Nomos (the AI agent) is the primary
operator interface — it has 21 MCP tools for observe/orient/decide/act. operator interface — it has 33 MCP tools for observe/orient/decide/act
- **Actions** (restart, logs, apt, pct exec): Nomos calls `request_execution` (§3).
via MCP. `reversible_low` actions execute immediately; `config_mutation` - **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
and `destructive` actions are queued for operator approval via Matrix. `run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration). immediately; `config_mutation` and `destructive` actions are queued for
Never hardcode secrets — use env vars from `.env`. operator approval via Matrix or the control-room UI's Operations page.
- **Secrets**: managed by Infisical (`oikos secret` subcommand for
migration). Never hardcode secrets — use env vars from `.env`.
- **Mutations** (restart, edit configs, etc.): classified against - **Mutations** (restart, edit configs, etc.): classified against
`seeds/policy.yaml`. `reversible_low` actions auto-execute; `seeds/policy.yaml`. `reversible_low` actions auto-execute;
`config_mutation`/`destructive` actions require approval. `config_mutation`/`destructive` actions require approval — granted by
a valid `--approval-id` from `homelab approval request` — see OIKOS.md. the operator via Matrix reply or the control-room UI, not a CLI flag.
See OIKOS.md.
## 6. Communication mode ## 7. Communication mode
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
terse-communication standard — drop filler, keep substance, use fragments. terse-communication standard — drop filler, keep substance, use fragments.
## 7. Auto-setup mechanism ## 8. Auto-setup mechanism
The homelab-context repo ships tooling that gets automatically installed The homelab-context repo ships tooling that gets automatically installed
on every client after `git pull`. This is handled by `tools/post-pull.sh` on every client after `git pull`. This is handled by `tools/post-pull.sh`
(replaces the raw git pull in the sync timer) which runs any script matching (replaces the raw git pull in the sync timer) which runs any script matching
`tools/*.setup.sh` after pull. `tools/setup-*.sh` after pull.
Currently auto-setup: Currently auto-setup:
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm - **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
package, wrapper scripts, and compact output templates for token-efficient health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`. `ssh-script` check kind depends on these actually being there — 20 are
- **Nomos agent persona** (`tools/setup-nomos-soul.sh`): Provisions live in the DB as of 2026-07-12.
`~/.nomos/SOUL.md` from `NOMOS.md` on Nomos agents. This ensures every
Nomos agent follows the canonical homelab persona (token efficiency, source
of truth hierarchy). No-op on non-Nomos agents.
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo, To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
commit and push. All enrolled clients pick it up within 5 minutes. commit and push. All enrolled clients pick it up within 5 minutes.
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer. To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
wait for the 5-min timer. (This mechanism — and the server-side
`tools_changed` detection behind it — only correctly recognized
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
nothing, so nothing auto-ran on any client via this path.)
## 8. When in doubt ## 9. Versioning
Every commit to `main` **MUST bump the version** in the `VERSION` file at the
repo root. The format is semver-ish: `major.minor.patch` (e.g. `0.2.3`).
Rules:
- **patch** (`0.2.2``0.2.3`): bugfixes, small tweaks, docs-only changes
- **minor** (`0.2.3``0.3.0`): new features, new tools, visible functionality
- **major** (`0.3.0``1.0.0`): breaking changes (API removal, tool retirement)
The version is shown in the UI sidebar. The `v` prefix is added at build time.
## 10. When in doubt
Use MCP tools: `search_knowledge <query>` for narrative context, Use MCP tools: `search_knowledge <query>` for narrative context,
`get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for `get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for

View File

@@ -24,9 +24,22 @@ Docker stack on mac-mini and exposes an MCP server + REST API.
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` | | State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
| Secrets (Infisical) | REST API + `oikos secret` CLI | | Secrets (Infisical) | REST API + `oikos secret` CLI |
| Approval tokens | Matrix via notifier | | Approval tokens | Matrix via notifier |
| Run a command on a host/LXC (policy-gated) | MCP `run` |
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
All MCP tools are read-only. Mutations use the `homelab` CLI with operator Most MCP tools are read-only; a few mutate the knowledge graph (recording
approval. what you learned) or the live infrastructure (`run`),
gated by risk classification and — for `config_mutation`/`destructive`
actions — operator approval. See [AGENTS.md](AGENTS.md#3-the-mcp-server) for
the full tool catalog.
## Authentication
Every API/MCP call needs `Authorization: Bearer <token>` — there is no
unauthenticated path except `POST /api/v1/clients/enroll` and `/healthz`.
Enrollment (below) does not currently hand out a per-client bearer token;
get the shared `OIKOS_MCP_BEARER_TOKEN` from the operator until per-client
token issuance exists.
## Enrollment ## Enrollment
@@ -41,7 +54,6 @@ curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh | sudo b
# Or with optional tooling: # Or with optional tooling:
curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config
curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
``` ```
This calls `POST /api/v1/clients/enroll` on the Oikos API, which: This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
@@ -56,7 +68,7 @@ This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
### What changes on your machine ### What changes on your machine
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) - `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
- `/opt/homelab/tools/` — tooling scripts (caveman, nomos-soul) - `/opt/homelab/tools/` — tooling scripts (checks)
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback) - `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
- `/etc/infisical/identity` — Infisical machine identity (primary secrets) - `/etc/infisical/identity` — Infisical machine identity (primary secrets)
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates - Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates

View File

@@ -9,10 +9,13 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
- **Go 1.26+** (see `go.mod` for pinned version) - **Go 1.26+** (see `go.mod` for pinned version)
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16` - **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
- **Docker** for the full dev stack - **Docker** for the full dev stack
- **Node 22+** for `web/` (the control-room SPA — standalone, not part of the
compose stack or the `oikos` binary)
```bash ```bash
# Start dependencies (Postgres + Redis) # Start dependencies (Postgres + Redis). api/nomos require a shared bearer
docker compose --profile dev up -d # token — no dev-open bypass — so set one even for local dev.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
# Run all tests # Run all tests
make test make test
@@ -22,13 +25,48 @@ make test-db
# Build the binary # Build the binary
make build make build
# SPA dev server (proxies to api/nomos, injecting the same token)
cd web && OIKOS_API_TOKEN=dev-token npm run dev
# Desktop app (macOS)
make desktop # build .app bundle
make install # build + install to /Applications
./cmd/desktop/build/bin/oikos-desktop.app/Contents/MacOS/oikos-desktop # run from terminal to see logs
``` ```
### Desktop app auth
The desktop app uses the same API as the browser SPA. First launch:
1. Enter `https://oikos.hubris.network` as Server URL
2. **Login with Authentik** tab → opens system browser → authenticate
3. Callback page shows token → copy → paste into Token tab → Connect
4. Token is persisted to the macOS keychain — subsequent launches skip setup
The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hubris.oikos-desktop`).
### Desktop app auto-update
- Checks Gitea releases every 6 hours
- System tray → **Check for Updates** triggers an immediate check
- Download, extract, replace the app in `/Applications`, and relaunch
- Versions are compared against the `version` const in `main.go`
## Project structure ## Project structure
``` ```
cmd/desktop/ Wails v3 desktop app (macOS + Linux)
main.go Thin shell: webview, system tray, notifications, auto-update
wails.json Wails project config
entitlements.plist macOS code-signing entitlements
icon.png System tray icon (embedded)
icon.icns App bundle icon (white logo on black rounded rect)
Taskfile.yml Wails v3 build tasks
Info.plist.template macOS bundle metadata
cmd/oikos/ Single-binary entry point cmd/oikos/ Single-binary entry point
cmd/nomos/ Nomos MCP client gateway cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ All Go packages internal/ All Go packages
httpapi/ REST + MCP server (OpenAPI-generated) httpapi/ REST + MCP server (OpenAPI-generated)
mcp/ MCP tool implementations mcp/ MCP tool implementations
@@ -42,15 +80,20 @@ internal/ All Go packages
domain/ Core types: entities, approvals, signals, patterns domain/ Core types: entities, approvals, signals, patterns
ontology/ Type hierarchy, relationship validation ontology/ Type hierarchy, relationship validation
knowledge/ Knowledge YAML seed ingestion knowledge/ Knowledge YAML seed ingestion
web/ Control-room SPA (Svelte 5) — standalone, not embedded
in the oikos binary; see plans/2026-07-12-wails-desktop-app.md
api/openapi.yaml API contract — the source of truth for endpoints api/openapi.yaml API contract — the source of truth for endpoints
migrations/ Forward-only SQL migrations (TimescaleDB) migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
compose/ Dockerfiles + Caddy config compose/ Dockerfiles + Caddy config
scripts/ Deploy, watchdog, rollback scripts/ Deploy, watchdog, rollback
checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks)
nomos/ Nomos config, persona, skills nomos/ Nomos config, persona, skills
.agents/ Agent instruction files + skills .agents/ Agent instruction files + skills
plans/ Design documents plans/ Design documents
docs/adr/ Architecture decision records docs/adr/ Architecture decision records
docs/operations/ Runbooks (rollback, etc.)
``` ```
## Commands ## Commands
@@ -68,6 +111,13 @@ docs/adr/ Architecture decision records
| `make export` | Export DB state to YAML seeds | | `make export` | Export DB state to YAML seeds |
| `make dev` | Start compose dev stack | | `make dev` | Start compose dev stack |
| `make clean` | Remove binary + test cache | | `make clean` | Remove binary + test cache |
| `make ui` | Build the SPA (`web/dist/`) |
| `make deploy-ui` | Build + deploy the SPA to the Caddy host |
| `make desktop` | Build the Wails desktop app for the current platform |
| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) |
| `make install` | Build + install to `/Applications` (macOS) |
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
| `make tidy` | `go mod tidy` |
## Conventions ## Conventions

View File

@@ -1,4 +1,4 @@
.PHONY: build test test-db lint generate generate-check dev migrate seed export clean tidy .PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install
BINARY := oikos BINARY := oikos
GO ?= go GO ?= go
@@ -6,6 +6,9 @@ GO ?= go
build: build:
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos $(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
webhook:
$(GO) build -o webhook -tags timetzdata ./cmd/webhook
test: test:
$(GO) test -race -cover ./... $(GO) test -race -cover ./...
@@ -42,8 +45,44 @@ export:
dev: dev:
docker compose --profile dev up -d docker compose --profile dev up -d
# Local sanity-check build of the SPA. Not embedded in the oikos binary
# (plans/2026-07-12-wails-desktop-app.md 0.1) — deploys as its own
# container (compose/web/Dockerfile) via `docker compose --profile full
# up -d web`, same push-to-main pipeline as everything else.
ui:
cd web && npm run build
desktop: ui ## Build the Wails desktop app for the current platform
rm -rf cmd/desktop/frontend/dist
mkdir -p cmd/desktop/frontend/dist
cp -r web/dist/* cmd/desktop/frontend/dist/
cd cmd/desktop && CGO_ENABLED=1 go build -o build/bin/Oikos .
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
@case $$(uname -s) in \
Darwin) \
APP="cmd/desktop/build/bin/Oikos.app"; \
rm -rf "$$APP"; \
mkdir -p "$$APP/Contents/MacOS"; \
mkdir -p "$$APP/Contents/Resources"; \
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
sed 's/$$(VERSION)/0.1.0/' cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
Linux) \
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
esac
@echo "Package: cmd/desktop/build/bin/"
install: desktop-package ## Install to /Applications
rm -rf /Applications/Oikos.app
cp -r cmd/desktop/build/bin/Oikos.app /Applications/
@echo "Installed to /Applications/Oikos.app"
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
rm -rf cmd/desktop/build
rm -rf cmd/desktop/frontend/dist
$(GO) clean -testcache $(GO) clean -testcache
tidy: tidy:

View File

@@ -13,18 +13,24 @@ learns from outcomes, and escalates when uncertain.
## Quick start ## Quick start
```bash ```bash
# Dev stack (postgres + api + scheduler + notifier) # Dev stack (postgres + api + scheduler + notifier). The api/nomos
docker compose --profile dev up -d # services need a shared token — every route requires a real bearer
# credential, there's no dev-open bypass.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
# Full stack (adds Nomos agent gateway) # Full stack (adds Nomos agent gateway)
docker compose --profile full up -d OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d
# Build standalone binary # Build standalone binary
go build -o bin/oikos -tags timetzdata ./cmd/oikos go build -o bin/oikos -tags timetzdata ./cmd/oikos
# Run all roles in one process (dev mode) # Run all roles in one process (dev mode)
OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \
OIKOS_API_TOKEN=dev-token \
go run ./cmd/oikos all go run ./cmd/oikos all
# Control-room SPA (separate from the Go binary — see web/)
cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
``` ```
## Architecture ## Architecture
@@ -59,16 +65,19 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks | | 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback | | 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md). Full plan: [plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
## Operations ## Operations
### API endpoints ### API endpoints
```bash ```bash
curl http://localhost:8090/api/v1/entities?type=service # fleet curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
curl http://localhost:8090/api/v1/health # fleet health http://localhost:8090/api/v1/entities?type=service # fleet
curl http://localhost:8090/api/v1/agent-activity # agent log curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
http://localhost:8090/api/v1/health # fleet health
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
http://localhost:8090/api/v1/agent-activity # agent log
``` ```
### Nomos queries ### Nomos queries
@@ -97,24 +106,40 @@ oikos secret list # enumerate SOPS secrets
oikos secret migrate # SOPS → Infisical oikos secret migrate # SOPS → Infisical
``` ```
### Web UI
`web/` is a standalone Svelte 5 SPA — not embedded in the `oikos` binary, not
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
output). A native desktop wrapper is planned — see
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md).
## Repo layout ## Repo layout
``` ```
cmd/oikos/ Go entry point — single binary cmd/oikos/ Go entry point — single binary
cmd/nomos/ Nomos MCP client gateway cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning, internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
notifier, policy, secrets, db, config, ontology, domain, notifier, policy, secrets, db, config, ontology, domain,
knowledge) knowledge)
web/ Control-room SPA (Svelte 5) — standalone, not embedded
api/openapi.yaml API contract (OpenAPI 3.1) api/openapi.yaml API contract (OpenAPI 3.1)
migrations/ Forward-only SQL migrations (TimescaleDB) migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge) seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
compose/ Dockerfiles + Caddy config compose/ Dockerfiles + Caddy config
scripts/ Deploy, watchdog, verification, rollback scripts/ Deploy, watchdog, verification, rollback
checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks)
ssh/ Deploy keys + authorized_keys management
vps/ Caddy/TURN config templates for the netbird VPS
nomos/ Nomos config, persona, skills nomos/ Nomos config, persona, skills
.agents/ Agent instruction files, shared conventions, skills .agents/ Agent instruction files, shared conventions, skills
archive/ Historical reference (legacy wiki, plans, SOPS backups) archive/ Historical reference (legacy wiki, plans, SOPS backups)
plans/ Design documents (active + done) plans/ Design documents (active + done)
docs/adr/ Architecture decision records docs/adr/ Architecture decision records
docs/operations/ Runbooks (rollback, etc.)
``` ```
## For agents ## For agents

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.7.6

View File

@@ -3,15 +3,14 @@
# #
# Thin client model (rev 2): no git clone, no sync timer. Fetches only the # Thin client model (rev 2): no git clone, no sync timer. Fetches only the
# agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) and tooling # agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) and tooling
# (caveman, nomos-soul) from the raw Gitea URL. Enrolls via the Oikos API # (checks) from the raw Gitea URL. Enrolls via the Oikos API to receive an
# to receive an age keypair and Infisical machine identity. A lightweight # age keypair and Infisical machine identity. A lightweight context poller
# context poller replaces the old 5-minute git pull. # replaces the old 5-minute git pull.
# #
# Usage: # Usage:
# curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh \ # curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh \
# | sudo bash # | sudo bash
# curl ... | sudo bash -s -- --with-mcp # wire Claude's .mcp.json # curl ... | sudo bash -s -- --with-mcp # wire Claude's .mcp.json
# curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
# curl ... | sudo bash -s -- --dry-run # show what would happen # curl ... | sudo bash -s -- --dry-run # show what would happen
# #
# Prerequisites: # Prerequisites:
@@ -28,16 +27,10 @@ REPO_RAW_URL="${HOMELAB_RAW_URL:-https://git.hubris.network/dtoro/oikos/raw/main
OIKOS_API_URL="${HOMELAB_OIKOS_URL:-https://oikos.hubris.network/api/v1}" OIKOS_API_URL="${HOMELAB_OIKOS_URL:-https://oikos.hubris.network/api/v1}"
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}" CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}" MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
NOMOS_MCP_URI="${HOMELAB_NOMOS_MCP_URI:-https://mcp.hubris.network/mcp}"
NOMOS_MODEL="${HOMELAB_NOMOS_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0 WITH_MCP=0
WITH_NOMOS=0
DRY_RUN=0 DRY_RUN=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
# ── helpers ────────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────────
log() { echo "[oikos] $*"; } log() { echo "[oikos] $*"; }
@@ -79,10 +72,7 @@ detect_mesh_ip() {
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--with-mcp) WITH_MCP=1 ;; --with-mcp) WITH_MCP=1 ;;
--with-nomos) WITH_NOMOS=1 ;;
--dry-run) DRY_RUN=1 ;; --dry-run) DRY_RUN=1 ;;
--gitea-token) GITEA_TOKEN="$2"; shift ;;
--gitea-user) GITEA_USER="$2"; shift ;;
*) die "unknown flag: $1" ;; *) die "unknown flag: $1" ;;
esac esac
shift shift
@@ -148,7 +138,7 @@ done
# ── fetch tools ────────────────────────────────────────────────────── # ── fetch tools ──────────────────────────────────────────────────────
log "fetching tools..." log "fetching tools..."
for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh post-pull.sh; do for tool in setup-checks.sh post-pull.sh; do
url="$REPO_RAW_URL/tools/${tool}" url="$REPO_RAW_URL/tools/${tool}"
dest="$CLONE_DIR/tools/${tool}" dest="$CLONE_DIR/tools/${tool}"
dry mkdir -p "$(dirname "$dest")" dry mkdir -p "$(dirname "$dest")"
@@ -161,16 +151,6 @@ for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh p
fi fi
done done
# ── fetch caveman templates ──────────────────────────────────────────
for tmpl in git_log.txt git_status.txt test_results.txt; do
url="$REPO_RAW_URL/tools/caveman/templates/${tmpl}"
dest="$CLONE_DIR/tools/caveman/templates/${tmpl}"
dry mkdir -p "$(dirname "$dest")"
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
fi
done
# ── detect control-plane (use localhost if API is reachable directly) ─ # ── detect control-plane (use localhost if API is reachable directly) ─
if [ -z "${HOMELAB_OIKOS_URL:-}" ]; then if [ -z "${HOMELAB_OIKOS_URL:-}" ]; then
if curl -s --connect-timeout 2 http://localhost:8090/api/v1/health >/dev/null 2>&1; then if curl -s --connect-timeout 2 http://localhost:8090/api/v1/health >/dev/null 2>&1; then
@@ -300,7 +280,7 @@ case "$OS" in
esac esac
# ── run auto-setup scripts ─────────────────────────────────────────── # ── run auto-setup scripts ───────────────────────────────────────────
for setup in "$CLONE_DIR"/tools/*.setup.sh; do for setup in "$CLONE_DIR"/tools/setup-*.sh; do
[ -f "$setup" ] || continue [ -f "$setup" ] || continue
log "running setup: $(basename "$setup")" log "running setup: $(basename "$setup")"
dry bash "$setup" dry bash "$setup"
@@ -319,17 +299,6 @@ if [ "$WITH_MCP" -eq 1 ]; then
log " + MCP wired to $MCP_URL" log " + MCP wired to $MCP_URL"
fi fi
# ── --with-nomos: install Goose + Nomos wrapper ────────────────────
if [ "$WITH_NOMOS" -eq 1 ]; then
log "installing Nomos agent..."
GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-${OS}-${ARCH:-amd64}"
if [ "$OS" = Darwin ]; then GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-darwin-${ARCH:-arm64}"; fi
dry curl -fsSL "$GOOSE_URL" -o /usr/local/bin/goose 2>/dev/null && chmod +x /usr/local/bin/goose || warn "goose not installed"
# Drop Nomos persona
cp "$CLONE_DIR/NOMOS.md" "$CLONE_DIR/.agents/NOMOS.md" 2>/dev/null || true
log " + Nomos agent installed"
fi
# ── netbird SSH JWT cache ──────────────────────────────────────────── # ── netbird SSH JWT cache ────────────────────────────────────────────
if command -v netbird >/dev/null 2>&1; then if command -v netbird >/dev/null 2>&1; then
dry netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 2>/dev/null || true dry netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 2>/dev/null || true

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Oikos</string>
<key>CFBundleIdentifier</key>
<string>com.hubris.oikos-desktop</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Oikos</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(VERSION)</string>
<key>CFBundleVersion</key>
<string>$(VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Hubris. All rights reserved.</string>
</dict>
</plist>

14
cmd/desktop/Taskfile.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3'
tasks:
build:
summary: Build the Oikos desktop app
cmds:
- go build -o build/bin/Oikos .
env:
CGO_ENABLED: 1
dev:
summary: Run in development mode
cmds:
- go run .

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<false/>
<key>com.apple.security.device.camera</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<false/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.hubris.oikos-desktop</string>
</array>
</dict>
</plist>

BIN
cmd/desktop/icon.icns Normal file

Binary file not shown.

BIN
cmd/desktop/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

784
cmd/desktop/main.go Normal file
View File

@@ -0,0 +1,784 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/zalando/go-keyring"
)
//go:embed frontend/dist
var assets embed.FS
//go:embed icon.png
var iconPNG []byte
const (
keyringService = "com.hubris.oikos-desktop"
keyringUser = "oikos"
version = "0.1.0"
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour
oidcCallbackPort = 18901
)
type OikosConfig struct {
ApiUrl string `json:"apiUrl"`
Token string `json:"token,omitempty"`
IsDesktop bool `json:"isDesktop"`
}
// ---- ConfigService ----
type ConfigService struct{}
func (c *ConfigService) Name() string { return "config" }
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
data, _ := json.Marshal(cfg)
return keyring.Set(keyringService, keyringUser, string(data))
}
func (c *ConfigService) ClearConfig() error {
return keyring.Delete(keyringService, keyringUser)
}
func (c *ConfigService) GetStoredConfig() *OikosConfig {
return loadConfig()
}
func (c *ConfigService) EnableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
os.MkdirAll(dir, 0755)
exe, _ := os.Executable()
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hubris.oikos-desktop</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>`, exe)
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
}
func (c *ConfigService) DisableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
return os.Remove(path)
}
// ---- Local OIDC server (runs alongside the webview) ----
type oidcSession struct {
apiUrl string
verifier string
state string
ch chan string
}
var (
oidcSessionsMu sync.Mutex
oidcSessions = make(map[string]*oidcSession)
)
func startOIDCServer() *http.Server {
mux := http.NewServeMux()
cors := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
}
}
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
cors(w, r)
if r.Method == "OPTIONS" {
return
}
handler(w, r)
})
}
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
returnURL := r.URL.Query().Get("ret")
if apiUrl == "" {
http.Error(w, "apiUrl required", http.StatusBadRequest)
return
}
if returnURL == "" {
returnURL = "/?desktop=1"
}
oidcCfg, err := fetchOIDCConfig(apiUrl)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
verifier, challenge, _ := pkceParams()
state := randomString(32)
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
ch := make(chan string, 1)
oidcSessionsMu.Lock()
sessionID := randomString(16)
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
oidcSessionsMu.Unlock()
authURL := fmt.Sprintf("%s?%s",
oidcCfg.AuthorizationEndpoint,
url.Values{
"response_type": {"code"},
"client_id": {oidcCfg.ClientID},
"redirect_uri": {redirectURI},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
"scope": {"openid profile email"},
}.Encode(),
)
exec.Command("open", authURL).Start()
select {
case token := <-ch:
if token != "" {
c := &ConfigService{}
c.SaveConfig(apiUrl, token)
returnURL += "&token=" + url.QueryEscape(token)
}
case <-time.After(5 * time.Minute):
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<meta http-equiv="refresh" content="0;url=%s">
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
})
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
gotState := r.URL.Query().Get("state")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
oidcSessionsMu.Lock()
var session *oidcSession
var sessionID string
for id, s := range oidcSessions {
if s.state == gotState {
session = s
sessionID = id
break
}
}
oidcSessionsMu.Unlock()
if session == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid state."))
return
}
token, err := exchangeCode(
session.apiUrl,
code, session.verifier,
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
)
oidcSessionsMu.Lock()
delete(oidcSessions, sessionID)
oidcSessionsMu.Unlock()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Token exchange failed: %v", err)
session.ch <- ""
return
}
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
session.ch <- token
})
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
if apiUrl == "" {
http.Error(w, "apiUrl required", http.StatusBadRequest)
return
}
cfg, err := fetchOIDCConfig(apiUrl)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
})
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
latest := fetchLatestRelease()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
if latest == nil {
json.NewEncoder(w).Encode(map[string]string{"current": version})
return
}
hasAsset := false
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
hasAsset = true
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
break
}
}
json.NewEncoder(w).Encode(map[string]string{
"current": version,
"latest": latest.Version,
"has_asset": fmt.Sprintf("%t", hasAsset),
})
})
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
if err != nil {
log.Printf("OIDC server: %v", err)
return nil
}
log.Printf("OIDC server listening on %s", listener.Addr())
srv := &http.Server{Handler: mux}
go srv.Serve(listener)
return srv
}
// ---- Window persistence ----
type windowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
func windowStatePath() string {
usr, _ := user.Current()
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
}
func loadWindowState() *windowState {
data, err := os.ReadFile(windowStatePath())
if err != nil {
return nil
}
var ws windowState
if err := json.Unmarshal(data, &ws); err != nil {
return nil
}
if ws.Width < 200 || ws.Height < 200 {
return nil
}
return &ws
}
func saveWindowState(w application.Window) {
x, y := w.Position()
width, height := w.Size()
ws := windowState{X: x, Y: y, Width: width, Height: height}
data, _ := json.Marshal(ws)
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
os.MkdirAll(dir, 0755)
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
}
func loadConfig() *OikosConfig {
data, err := keyring.Get(keyringService, keyringUser)
if err != nil {
return nil
}
var cfg OikosConfig
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
return nil
}
cfg.IsDesktop = true
return &cfg
}
type oidcConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
}
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var cfg oidcConfig
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func pkceParams() (verifier, challenge string, _ error) {
v := randomString(64)
h := sha256.Sum256([]byte(v))
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
}
func randomString(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
body, _ := json.Marshal(map[string]string{
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"redirect_uri": redirectURI,
})
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
}
var tokens struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
return "", err
}
if tokens.AccessToken == "" {
return "", fmt.Errorf("no access_token in response")
}
return tokens.AccessToken, nil
}
// ---- Notifications ----
type dashboardSummary struct {
ApprovalsPending int `json:"approvals_pending"`
Signals struct {
Critical int `json:"critical"`
} `json:"signals_by_severity"`
}
func (d *dashboardSummary) alertCount() int {
return d.ApprovalsPending + d.Signals.Critical
}
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" {
return
}
script := fmt.Sprintf(
`display notification "%s" with title "%s" sound name "default"`,
strings.ReplaceAll(subtitle, `"`, `\"`),
strings.ReplaceAll(title, `"`, `\"`),
)
exec.Command("osascript", "-e", script).Run()
}
func pollDashboard(cfg *OikosConfig) {
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
return
}
var lastCount int
first := true
for {
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
if err != nil {
time.Sleep(pollInterval)
continue
}
req.Header.Set("Authorization", "Bearer "+cfg.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
time.Sleep(pollInterval)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var summary dashboardSummary
if err := json.Unmarshal(body, &summary); err != nil {
time.Sleep(pollInterval)
continue
}
if first {
lastCount = summary.alertCount()
first = false
} else {
current := summary.alertCount()
if current > lastCount {
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
}
lastCount = current
}
time.Sleep(pollInterval)
}
}
// ---- Auto-update ----
type giteaRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
type updateState struct {
mu sync.Mutex
latestURL string
}
var updater = &updateState{}
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
// the download URL and returns the latest version string (empty if current).
func (c *ConfigService) CheckForUpdates() string {
latest := fetchLatestRelease()
if latest == nil || latest.Version == version {
return ""
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
return latest.Version
}
}
return ""
}
// InstallUpdate downloads the stored update, replaces the app, and restarts.
func (c *ConfigService) InstallUpdate() error {
updater.mu.Lock()
url := updater.latestURL
updater.mu.Unlock()
if url == "" {
return fmt.Errorf("no update available")
}
return doUpdate(url)
}
type latestRelease struct {
Version string
Assets []struct {
Name string
BrowserDownloadURL string
}
}
func fetchLatestRelease() *latestRelease {
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
if err != nil {
return nil
}
defer resp.Body.Close()
var releases []giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
return nil
}
r := releases[0]
v := strings.TrimPrefix(r.TagName, "v")
if v == version {
return nil
}
lr := &latestRelease{Version: v}
for _, a := range r.Assets {
lr.Assets = append(lr.Assets, struct {
Name string
BrowserDownloadURL string
}{a.Name, a.BrowserDownloadURL})
}
return lr
}
func doUpdate(downloadURL string) error {
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
if _, err := io.Copy(tmp, resp.Body); err != nil {
return err
}
tmp.Close()
extractDir, err := os.MkdirTemp("", "oikos-extract")
if err != nil {
return err
}
defer os.RemoveAll(extractDir)
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unzip: %w: %s", err, out)
}
newApp := filepath.Join(extractDir, "Oikos.app")
if _, err := os.Stat(newApp); err != nil {
return fmt.Errorf("extracted app not found: %w", err)
}
currentApp := "/Applications/Oikos.app"
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
if exe, err := os.Executable(); err == nil {
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}
}
script := fmt.Sprintf(`#!/bin/bash
sleep 2
rm -rf "%s"
mv "%s" "%s"
open "%s"
rm "$0"
`, currentApp, newApp, currentApp, currentApp)
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
return err
}
app := application.Get()
exec.Command("open", scriptPath).Start()
if app != nil {
app.Quit()
}
return nil
}
func checkUpdates() {
for {
time.Sleep(updateInterval)
latest := fetchLatestRelease()
if latest == nil {
continue
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
app := application.Get()
if app == nil {
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
app.Dialog.Info().
SetTitle("Update Available").
SetMessage(msg).
Show()
break
}
}
}
}
// ---- Main ----
func main() {
oidcSrv := startOIDCServer()
defer oidcSrv.Close()
distFS, err := fs.Sub(assets, "frontend/dist")
if err != nil {
log.Fatalf("embedded assets: %v", err)
}
app := application.New(application.Options{
Name: "Oikos",
Description: "Homelab Control Room",
Services: []application.Service{
application.NewService(&ConfigService{}),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(distFS),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
})
systemTray := app.SystemTray.New()
systemTray.SetTooltip("Oikos")
systemTray.SetIcon(iconPNG)
trayMenu := application.NewMenu()
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
for _, w := range app.Window.GetAll() {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
go func() {
latest := fetchLatestRelease()
if latest == nil {
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
return
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
yes := d.AddButton("Install")
yes.OnClick(func() { doUpdate(updater.latestURL) })
no := d.AddButton("Later")
d.SetDefaultButton(yes)
d.SetCancelButton(no)
d.Show()
return
}
}
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
}()
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systemTray.SetMenu(trayMenu)
ws := loadWindowState()
width, height := 1400, 900
if ws != nil {
width = ws.Width
height = ws.Height
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Oikos",
Width: width,
Height: height,
MinWidth: 1024,
MinHeight: 700,
URL: "/?desktop=1",
})
if ws != nil {
window.SetPosition(ws.X, ws.Y)
} else {
window.Center()
}
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.Hide()
e.Cancel()
})
window.Show()
systemTray.AttachWindow(window)
systemTray.Run()
app.OnShutdown(func() {
saveWindowState(window)
})
go pollDashboard(loadConfig())
go checkUpdates()
err = app.Run()
if err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,5 @@
<svg width="88" height="88" viewBox="0 0 110 120" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(9, 10) scale(0.9)">
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 690 B

9
cmd/desktop/wails.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "oikos",
"outputfilename": "oikos-desktop",
"frontend:dir": "frontend",
"author": {
"name": "Hubris",
"email": "d.toro.v@pm.me"
}
}

View File

@@ -17,12 +17,26 @@ import (
) )
// maxIterations bounds one chat turn's tool-calling loop. Provisioning a // maxIterations bounds one chat turn's tool-calling loop. Provisioning a
// service is a long chain (research → plan → request_execution → per-step // service is a long chain (research → plan → run → per-step
// install/verify run calls), so this must be generous; a full deploy with the // install/verify run calls), so this must be generous; a full deploy with the
// decomposed pct_create flow can legitimately need many steps. On exhaustion // decomposed pct_create flow can legitimately need many steps. On exhaustion
// the loop now produces a real summary (finalSummary) rather than a dead end. // the loop now produces a real summary (finalSummary) rather than a dead end.
const maxIterations = 40 const maxIterations = 40
const maxLLMRetries = 1 const maxLLMRetries = 3
// historyWindowSize bounds how many of a session's most recent persisted
// messages are replayed into the LLM's context on each turn — see
// store.go's getRecentMessages for why this exists (fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: unbounded history replay was
// a real, observed-in-production cost/latency/eventual-context-limit risk).
// 30 is a fixed-window choice, not token-budget-aware: simplest option that
// still keeps roughly the current task's working context, at the cost of
// occasionally dropping something a very long task still needed — the
// system note injected when truncation happens tells the model to check
// upsert_knowledge/search_knowledge rather than assume something didn't
// happen. A token-aware trim or LLM-summarize-on-drop are documented
// stretch options if a fixed window proves insufficient in practice.
const historyWindowSize = 30
var refusalDenylist = []string{ var refusalDenylist = []string{
"我没有相关信息", "我没有相关信息",
@@ -33,7 +47,7 @@ var refusalDenylist = []string{
} }
type agent struct { type agent struct {
client *mcpClient clients *mcpClientPool // one MCP client PER SESSION, not shared — see mcpClientPool's doc comment
system string system string
provider *openai.Client provider *openai.Client
model string model string
@@ -41,10 +55,11 @@ type agent struct {
agentID uuid.UUID agentID uuid.UUID
reqOpts []option.RequestOption reqOpts []option.RequestOption
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass)
httpClient *http.Client httpClient *http.Client
} }
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) { func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
system := loadSoul() system := loadSoul()
apiKey := os.Getenv("OPENROUTER_API_KEY") apiKey := os.Getenv("OPENROUTER_API_KEY")
model := os.Getenv("NOMOS_MODEL") model := os.Getenv("NOMOS_MODEL")
@@ -92,7 +107,7 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
} }
return &agent{ return &agent{
client: mcpClient, clients: clients,
system: system, system: system,
provider: &provider, provider: &provider,
model: model, model: model,
@@ -100,6 +115,7 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
agentID: agentID, agentID: agentID,
reqOpts: reqOpts, reqOpts: reqOpts,
apiBase: apiBase, apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
}, nil }, nil
} }
@@ -113,7 +129,7 @@ func loadSoul() string {
} }
return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab. return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab.
You have access to MCP tools to query topology, health, knowledge, and request You have access to MCP tools to query topology, health, knowledge, and request
gated mutations through request_execution. Be concise. Prefer tools over guessing.` gated mutations through run. Be concise. Prefer tools over guessing.`
} }
// assentWindowDuration is how long after an operator approves a plan that // assentWindowDuration is how long after an operator approves a plan that
@@ -125,12 +141,15 @@ const assentWindowDuration = 30 * time.Minute
// openAssentWindow records an active assent window in autonomy_settings so // openAssentWindow records an active assent window in autonomy_settings so
// the MCP run tool (separate process) can check it before requiring approval // the MCP run tool (separate process) can check it before requiring approval
// for config_mutation commands. Key is scoped to this agent's UUID. // for config_mutation commands. Key is scoped to this agent's UUID AND this
func (a *agent) openAssentWindow(ctx context.Context) { // session/task — see store.go's assentWindowActive for why: without the
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil { // session dimension, approving one task's plan would silently auto-run
// unapproved actions in any other concurrently-running task.
func (a *agent) openAssentWindow(ctx context.Context, sessionID string) {
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil || sessionID == "" {
return return
} }
key := "assent_window.agent:" + a.agentID.String() key := assentWindowKey(a.agentID, sessionID)
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339) expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
_, err := a.store.pool.Exec(ctx, _, err := a.store.pool.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
@@ -138,7 +157,7 @@ func (a *agent) openAssentWindow(ctx context.Context) {
if err != nil { if err != nil {
slog.Warn("nomos: openAssentWindow", "error", err) slog.Warn("nomos: openAssentWindow", "error", err)
} else { } else {
slog.Info("nomos: assent window opened", "agent", a.agentID, "expires", expires) slog.Info("nomos: assent window opened", "agent", a.agentID, "session", sessionID, "expires", expires)
} }
} }
@@ -168,18 +187,57 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) { func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
correlationID := uuid.New().String() correlationID := uuid.New().String()
tools, err := a.buildTools() // emitError emits an error event followed by a done event. The done
// event is CRITICAL on every terminal path: the frontend's
// onComplete handler (chat.ts) treats a missing `done` as a severed
// network connection and triggers an auto-reconnect → resumeSession.
// Before this fix, a model empty-response (the most common case here)
// returned without `done`, was misclassified as a network drop, and
// the reconnect logic re-invoked the agent with a generic "report
// your state" note — which caused the agent to re-propose the plan
// and duplicate it in the sidebar (operator-reported 2026-07-14).
// Every error return below must go through emitError so the frontend
// shows the error inline instead of silently reconnecting.
emitError := func(data string) {
emit(agentEvent{Type: "error", Data: data, SessionID: sessionID})
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"correlation_id": correlationID,
"iterations": 0,
"error": true,
}, SessionID: sessionID})
}
tools, err := a.buildTools(sessionID)
if err != nil { if err != nil {
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID}) emitError(fmt.Sprintf("build tools: %v", err))
return return
} }
system := a.system system := a.system
if snapshot := a.fleetSnapshot(); snapshot != "" { if snapshot := a.fleetSnapshot(sessionID); snapshot != "" {
system += "\n\n" + snapshot system += "\n\n" + snapshot
} }
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)} messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, _ := a.store.getMessages(ctx, sessionID) history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
if truncatedHistory {
// Tell the model explicitly rather than silently dropping older
// turns — otherwise it might assume something wasn't done just
// because it doesn't see the turn that did it.
messages = append(messages, openai.SystemMessage(fmt.Sprintf(
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
historyWindowSize)))
}
// sawSetGoal / sawCompleteTask track whether this session has EVER framed
// itself as a structured task (set_goal) or already reached a terminal
// state (complete_task) — across both replayed history and this turn's
// own tool calls (updated again below as they happen live). Used by the
// end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md,
// fix 1): most sessions are a single trivial Q&A exchange that answers in
// text and never calls either tool, leaving agent_sessions.status stuck
// at its creation-time default forever. If a session never framed itself
// as a task, its first plain-text turn-end IS the task ending.
var sawSetGoal, sawCompleteTask bool
var lastAssistantCalls []persistedCall var lastAssistantCalls []persistedCall
for _, m := range history { for _, m := range history {
text := extractText(m.Content) text := extractText(m.Content)
@@ -191,6 +249,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
messages = append(messages, assistantToolCallMessage(calls)) messages = append(messages, assistantToolCallMessage(calls))
for _, c := range calls { for _, c := range calls {
messages = append(messages, openai.ToolMessage(c.resultText(), c.id)) messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
switch c.name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
} }
lastAssistantCalls = calls lastAssistantCalls = calls
} }
@@ -232,8 +296,6 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if ok { if ok {
granted = append(granted, p.execID) granted = append(granted, p.execID)
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID) slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID})
emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID})
// An explicit typed confirmation for a destructive action // An explicit typed confirmation for a destructive action
// opens a short, target-scoped window so the rest of a // opens a short, target-scoped window so the rest of a
@@ -242,31 +304,43 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if p.destructive && typedConfirm { if p.destructive && typedConfirm {
if execUUID, perr := uuid.Parse(p.execID); perr == nil { if execUUID, perr := uuid.Parse(p.execID); perr == nil {
if target := a.store.executionTarget(ctx, execUUID); target != "" { if target := a.store.executionTarget(ctx, execUUID); target != "" {
a.store.openDestructiveWindow(ctx, a.agentID, target) a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID)
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target) slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID)
} }
} }
} }
} }
} }
if len(granted) > 0 { if len(granted) > 0 {
a.openAssentWindow(ctx) a.openAssentWindow(ctx, sessionID)
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. An assent window is now active for 30 minutes: config_mutation commands will auto-run without re-approval. Do not re-request or call request_execution/run again for these; check get_execution_status if you need the outcome. CONTINUE executing the full plan — do not stop and wait for 'continue' after each step. Only surface to the operator for destructive actions (need typed confirmation) or if you're genuinely stuck after trying alternatives.]", strings.Join(granted, ", ")) // Mark approved executions as continued so the continuation
messages = append(messages, openai.SystemMessage(note)) // worker doesn't call resumeSession while the chat handler is
// still processing "go ahead" — two concurrent LLM calls for the
// same session cause empty responses and race conditions.
for _, execID := range granted {
if execUUID, perr := uuid.Parse(execID); perr == nil {
a.store.markContinued(ctx, execUUID)
}
}
// No system note. The model already sees "go ahead" in the
// replayed history (the user message was saved to the DB before
// chat() was called). The old note said "they are now running"
// which made the model think work was being done for it —
// causing empty responses (finish_reason=stop, content_len=0).
// The approved executions are dispatched; the model will
// continue with the remaining plan steps naturally.
} }
if len(blocked) > 0 { if len(blocked) > 0 {
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", ")) note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", "))
messages = append(messages, openai.SystemMessage(note)) messages = append(messages, openai.SystemMessage(note))
} }
} else if assent && len(lastAssistantCalls) == 0 { } else if assent && len(pending) == 0 {
// The operator said "proceed"/"go ahead"/"yes" but the preceding // The operator said "proceed"/"go ahead"/"yes" but there are no
// assistant turn had NO pending approvals — meaning the agent // pending approvals — the agent proposed a plan (via propose_plan)
// proposed a plan in text and asked "shall I?" without calling // and asked "shall I?" Open the assent window silently. No system
// request_execution yet. Inject a system note telling the agent // note: the model sees "go ahead" in the replayed history and
// the operator approved — go execute the plan now. // responds naturally.
note := "[System: The operator approved your proposed plan. Execute it now — call request_execution or run to carry out the steps you described. Do not re-describe the plan or ask for confirmation again. The assent window is active: config_mutation commands will auto-run once you create them.]" a.openAssentWindow(ctx, sessionID)
messages = append(messages, openai.SystemMessage(note))
a.openAssentWindow(ctx)
} }
// Worker continuation: append the finished-execution note so the model // Worker continuation: append the finished-execution note so the model
@@ -302,7 +376,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID) slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
continue continue
} }
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID}) emitError(fmt.Sprintf("llm: %v", err))
return return
} }
if len(acc.Choices) == 0 { if len(acc.Choices) == 0 {
@@ -310,21 +384,32 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID) slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
continue continue
} }
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID}) emitError("no choices in response (the model returned zero completions — likely a provider or rate-limit issue)")
return return
} }
msg = acc.Choices[0].Message msg = acc.Choices[0].Message
finishReason := acc.Choices[0].FinishReason
if len(msg.ToolCalls) == 0 { if len(msg.ToolCalls) == 0 {
if isRefusalOrEmpty(msg.Content) { if isRefusalOrEmpty(msg.Content) {
if attempt < maxLLMRetries { if attempt < maxLLMRetries {
slog.Warn("nomos: empty or refusal response, retrying", slog.Warn("nomos: empty or refusal response, retrying",
"session", sessionID, "iter", i+1, "attempt", attempt+1, "session", sessionID, "iter", i+1, "attempt", attempt+1,
"content_len", len(msg.Content)) "content_len", len(msg.Content), "finish_reason", finishReason)
continue continue
} }
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID}) // B.4: surface the real error context (finish_reason +
// refusal text) instead of a generic "empty response" —
// the operator can tell "content_filter — rephrase" from
// "length — token limit hit" from "stop — model no-op'd".
detail := "empty response"
if msg.Refusal != "" {
detail = fmt.Sprintf("refusal: %s", msg.Refusal)
} else if finishReason != "" && finishReason != "stop" {
detail = fmt.Sprintf("finish_reason=%s", finishReason)
}
emitError(fmt.Sprintf("Nomos returned an empty or unusable response (%s). Retry or rephrase.", detail))
return return
} }
} }
@@ -333,6 +418,17 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if len(msg.ToolCalls) == 0 { if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
if !sawSetGoal && !sawCompleteTask {
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
}
// Safety net: if the agent called set_goal (structured task)
// but didn't call complete_task, and all plan steps are
// terminal, auto-complete. The model often does the work but
// forgets to close the loop (confirmed live: the #1 remaining
// model reliability gap after D.1).
if !sawCompleteTask {
a.autoCompleteIfPlanDone(ctx, sessionID, msg.Content)
}
emit(agentEvent{Type: "done", Data: map[string]any{ emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID, "session_id": sessionID,
"usage": acc.Usage, "usage": acc.Usage,
@@ -342,6 +438,18 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
return return
} }
// P3: persist intermediate reasoning. When the model produces text
// AND tool calls in the same iteration, the text is its reasoning
// before the tool calls — the operator saw it live via text_delta,
// but without emitting it as a `text` event here, the persist layer
// (main.go/continue.go) never captures it and a reload shows only
// the final summary + a flat tool-call list, not the thinking that
// led to each step. Emitting it lets the persist layer accumulate
// per-iteration reasoning into the row's text field.
if strings.TrimSpace(msg.Content) != "" {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
}
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID) slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
messages = append(messages, msg.ToParam()) messages = append(messages, msg.ToParam())
@@ -352,6 +460,13 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
args = map[string]any{} args = map[string]any{}
} }
switch tc.Function.Name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
emit(agentEvent{ emit(agentEvent{
Type: "tool_use", Type: "tool_use",
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID}, Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
@@ -360,14 +475,38 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}) })
start := time.Now() start := time.Now()
result, callErr := a.client.callTool(tc.Function.Name, args) // Session-scoped task tools are handled in-process; everything else
// is forwarded to the shared MCP server.
var result any
var callErr error
if localRes, handled := a.handleTaskTool(ctx, sessionID, tc.Function.Name, args); handled {
result = localRes
} else {
// _session_id rides along on the wire call only — never in
// `args` (which is what gets emitted/logged/persisted as the
// model's own tool call) — so the MCP-side assent/destructive
// window checks can scope to THIS task instead of bleeding
// across every concurrently-running one sharing this agent
// identity. Not part of any tool's declared InputSchema, so
// the model never sees or supplies it.
wireArgs := make(map[string]any, len(args)+1)
for k, v := range args {
wireArgs[k] = v
}
wireArgs["_session_id"] = sessionID
var client *mcpClient
client, callErr = a.clients.get(sessionID)
if callErr == nil {
result, callErr = client.callTool(tc.Function.Name, wireArgs)
}
}
elapsed := int(time.Since(start).Milliseconds()) elapsed := int(time.Since(start).Milliseconds())
inputJSON, _ := json.Marshal(args) inputJSON, _ := json.Marshal(args)
inputStr := string(inputJSON) inputStr := string(inputJSON)
if callErr != nil { if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID) a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
emit(agentEvent{ emit(agentEvent{
Type: "tool_result", Type: "tool_result",
@@ -381,7 +520,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
} }
resultJSON, _ := json.Marshal(result) resultJSON, _ := json.Marshal(result)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID) a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
// Link any execution this tool queued/started back to this // Link any execution this tool queued/started back to this
// session, so the auto-continuation worker can feed its result // session, so the auto-continuation worker can feed its result
@@ -392,6 +531,17 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
a.store.linkExecution(ctx, execID, sessionID) a.store.linkExecution(ctx, execID, sessionID)
} }
// Record which entities this task touched (task —involves→ entity)
// and pulse them on the live context panel. Args only — never
// results — so a bulk query doesn't drag the whole fleet in.
a.store.recordTouched(ctx, sessionID, tc.Function.Name, args)
// When the agent records knowledge, link that note to this task so
// the task's outcome view shows what it learned (and pulse it live).
if tc.Function.Name == "upsert_knowledge" {
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON))
}
emit(agentEvent{ emit(agentEvent{
Type: "tool_result", Type: "tool_result",
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID}, Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
@@ -400,6 +550,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}) })
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID)) messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed) slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
// ask_operator pauses the task: the agent has posed a decision only
// the operator can make. End the turn here so it doesn't barrel past
// its own question — the answer (panel or chat reply) resumes it.
// The prompt becomes the assistant's visible message so the question
// also shows inline in the transcript.
if tc.Function.Name == "ask_operator" {
prompt, _ := args["prompt"].(string)
emit(agentEvent{Type: "text", Data: prompt, SessionID: sessionID})
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"correlation_id": correlationID,
"iteration": i + 1,
}, SessionID: sessionID})
return
}
} }
} }
@@ -414,6 +580,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state." summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state."
} }
emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID}) emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID})
if !sawCompleteTask {
a.autoCompleteIfPlanDone(ctx, sessionID, summary)
}
emit(agentEvent{Type: "done", Data: map[string]any{ emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID, "session_id": sessionID,
"correlation_id": correlationID, "correlation_id": correlationID,
@@ -545,8 +714,12 @@ func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessag
// of spending its first iteration rediscovering topology it already has // of spending its first iteration rediscovering topology it already has
// tools to query. Best-effort: an empty string on any failure just means no // tools to query. Best-effort: an empty string on any failure just means no
// snapshot, not an error for the turn. // snapshot, not an error for the turn.
func (a *agent) fleetSnapshot() string { func (a *agent) fleetSnapshot(sessionID string) string {
result, err := a.client.callTool("get_health_summary", map[string]any{}) client, err := a.clients.get(sessionID)
if err != nil {
return ""
}
result, err := client.callTool("get_health_summary", map[string]any{})
if err != nil { if err != nil {
return "" return ""
} }
@@ -609,11 +782,18 @@ func isRefusalOrEmpty(text string) bool {
return false return false
} }
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) { func (a *agent) buildTools(sessionID string) ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull() client, err := a.clients.get(sessionID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defs, err := client.listToolsFull()
if err != nil {
return nil, err
}
// Append nomos-local, session-scoped task tools (complete_task, …) to the
// MCP tool list. They're routed to handleTaskTool, not the MCP client.
defs = append(defs, taskToolDefs()...)
var tools []openai.ChatCompletionToolParam var tools []openai.ChatCompletionToolParam
for _, d := range defs { for _, d := range defs {
@@ -634,7 +814,23 @@ func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
return tools, nil return tools, nil
} }
// listToolsFull returns the MCP server's tool list, cached on this client
// after the first call (see mcpClient.toolsCache). Fix F1 of
// plans/2026-07-11-nomos-agent-code-review.md: buildTools calls this at the
// start of every chat turn, including every auto-continuation resume — the
// tool list is static for the lifetime of one MCP connection, so re-fetching
// it every single time was avoidable network+parsing work on the hot path.
// Cache invalidates on reconnectLocked (an api restart may change what's
// registered).
func (c *mcpClient) listToolsFull() ([]toolDef, error) { func (c *mcpClient) listToolsFull() ([]toolDef, error) {
c.toolsMu.Lock()
if c.toolsCache != nil {
cached := c.toolsCache
c.toolsMu.Unlock()
return cached, nil
}
c.toolsMu.Unlock()
resp, err := c.doRequest("tools/list", map[string]any{}) resp, err := c.doRequest("tools/list", map[string]any{})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -657,5 +853,9 @@ func (c *mcpClient) listToolsFull() ([]toolDef, error) {
InputSchema: t.InputSchema, InputSchema: t.InputSchema,
} }
} }
c.toolsMu.Lock()
c.toolsCache = out
c.toolsMu.Unlock()
return out, nil return out, nil
} }

View File

@@ -53,10 +53,18 @@ func extractPendingApprovals(calls []persistedCall) []pendingApproval {
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait" // don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
// alone should also block a stray "yes" a sentence later — checking negation // alone should also block a stray "yes" a sentence later — checking negation
// first and returning false errs toward re-confirming rather than assuming // first and returning false errs toward re-confirming rather than assuming
// consent, per "when in doubt, escalate"). // consent, per "when in doubt, escalate"). Includes contracted negatives
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
// haven't confirmed anything yet" was reading as an explicit confirmation
// because none of the contracted forms were covered, only "don't"/"do not".
// Deliberately does NOT include a bare "not": that's broad enough to false-
// negative ordinary assent ("go ahead, this is not risky") — the specific
// contracted-verb forms below are unambiguous negation on their own.
var negationWords = []string{ var negationWords = []string{
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off", "no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that", "not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
} }
// assentWords, checked only if no negation matched. // assentWords, checked only if no negation matched.
@@ -66,19 +74,56 @@ var assentWords = []string{
"lgtm", "run it", "execute", "ok go", "okay go", "please do", "lgtm", "run it", "execute", "ok go", "okay go", "please do",
} }
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
// (straight ' and curly ) stay attached to their word so "don't"/"haven't"
// tokenize as one token, not two.
var wordTokenRe = regexp.MustCompile(`[a-z0-9']+`)
func tokenize(msg string) []string {
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "", "'")), -1)
}
// containsPhrase reports whether phrase (one or more words) appears as a
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
// match. This is the fix for a real false positive found live: the old
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
// for word boundaries. Negation already used a word-boundary check
// (space-padded); assent/confirm words didn't — this brings both onto the
// same, more robust tokenized comparison instead of ad-hoc string padding.
func containsPhrase(tokens []string, phrase string) bool {
words := strings.Fields(phrase)
if len(words) == 0 || len(words) > len(tokens) {
return false
}
for i := 0; i+len(words) <= len(tokens); i++ {
match := true
for j, w := range words {
if tokens[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
// isAssent reports whether msg is a plain-language authorization of a // isAssent reports whether msg is a plain-language authorization of a
// pending proposal. Deliberately simple and auditable: a fixed word list, // pending proposal. Deliberately simple and auditable: a fixed word list,
// not a model judgment call, so behavior is predictable and can't be // not a model judgment call, so behavior is predictable and can't be
// prompt-injected via the pending action's own content. // prompt-injected via the pending action's own content.
func isAssent(msg string) bool { func isAssent(msg string) bool {
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " " tokens := tokenize(msg)
for _, w := range negationWords { for _, w := range negationWords {
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") { if containsPhrase(tokens, w) {
return false return false
} }
} }
for _, w := range assentWords { for _, w := range assentWords {
if strings.Contains(m, w) { if containsPhrase(tokens, w) {
return true return true
} }
} }
@@ -93,13 +138,13 @@ func isAssent(msg string) bool {
// ("I confirm destroy 135"). Still negation-aware for the same reason as // ("I confirm destroy 135"). Still negation-aware for the same reason as
// isAssent: "don't confirm yet" must not accidentally match. // isAssent: "don't confirm yet" must not accidentally match.
func isTypedConfirmation(msg string) bool { func isTypedConfirmation(msg string) bool {
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " " tokens := tokenize(msg)
for _, w := range negationWords { for _, w := range negationWords {
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") { if containsPhrase(tokens, w) {
return false return false
} }
} }
return strings.Contains(m, "confirm") return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
} }
// approveExecution grants (or denies) a pending execution via the same HTTP // approveExecution grants (or denies) a pending execution via the same HTTP
@@ -119,6 +164,9 @@ func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, s
return false, "", err return false, "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if a.apiToken != "" {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
}
resp, err := a.httpClient.Do(req) resp, err := a.httpClient.Do(req)
if err != nil { if err != nil {
return false, "", err return false, "", err

View File

@@ -45,6 +45,43 @@ func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
} }
} }
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found
// live: the old substring check matched "yes" inside "yesterday" (and would
// equally match "confirm" inside "confirmed"/"unconfirmed" for
// isTypedConfirmation below) because only negation used a word-boundary
// check — assent/confirm words used a bare strings.Contains. Confirmed via a
// throwaway probe before being fixed; kept here permanently so a future
// change can't silently reintroduce it.
func TestIsAssent_WholeWordBoundary(t *testing.T) {
cases := []string{
"not sure, maybe yesterday's logs show something useful",
"my eyesight isn't great, what does that say",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
}
}
}
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and
// "confirm" matching inside "confirmed" combined with contracted negatives
// ("haven't") not being in negationWords meant a message that explicitly
// says the operator has NOT confirmed something could read as confirming it.
func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
cases := []string{
"I haven't confirmed anything yet, let me think",
"that isn't confirmed on my end",
"we can't confirm that until tomorrow",
}
for _, c := range cases {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
}
}
}
func TestIsTypedConfirmation(t *testing.T) { func TestIsTypedConfirmation(t *testing.T) {
positive := []string{ positive := []string{
"I confirm destroy 135 in strong", "I confirm destroy 135 in strong",

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -31,6 +32,72 @@ func extractExecutionIDs(toolResult string) []uuid.UUID {
return out return out
} }
// idleTaskThreshold is how long a goal-bearing session can sit non-terminal
// with no activity before the idle sweep nudges it, per
// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point,
// not measured against real task durations — long enough that it won't fire
// mid-turn, short enough the board doesn't lie for hours.
const idleTaskThreshold = 15 * time.Minute
// runIdleSweepWorker is the safety net for case 2 of
// plans/2026-07-11-task-completion-safety-net.md: sessions that called
// set_goal (so the inline safety net in agent.go correctly left them alone,
// since they framed themselves as a real task) but then stalled without
// ever calling complete_task. Coarser than runContinuationWorker's 4s tick
// since "gone idle" is a much slower signal than "an execution just
// finished." Blocks until ctx is cancelled.
func (a *agent) runIdleSweepWorker(ctx context.Context) {
if a.store == nil {
slog.Warn("nomos: idle sweep worker disabled (no store)")
return
}
slog.Info("nomos: idle sweep worker started")
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processIdleSweep(ctx)
}
}
}
// processIdleSweep nudges a stalled goal-bearing session once; if it's still
// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered,
// not just that the model is still working), auto-closes it with a
// visible "auto-closed" outcome instead of leaving it stuck forever — same
// reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all).
func (a *agent) processIdleSweep(ctx context.Context) {
stale := a.store.staleGoalSessions(ctx, idleTaskThreshold, 5)
for _, s := range stale {
s := s
if s.CompletionNudges == 0 {
safego.Go("nomos:idle-nudge:"+s.ID, func() {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
return
}
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold)
note = a.store.enrichResumeNote(ctx, s.ID, note)
a.resumeSession(ctx, s.ID, note)
})
continue
}
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
if err := a.store.completeTask(ctx, s.ID, "partial", summary); err != nil {
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
}
})
}
}
// runContinuationWorker is the event loop that replaces the human typing // runContinuationWorker is the event loop that replaces the human typing
// "continue". It polls for gated executions that (a) were initiated by a chat // "continue". It polls for gated executions that (a) were initiated by a chat
// session and (b) have just finished, and — while that agent has an open assent // session and (b) have just finished, and — while that agent has an open assent
@@ -55,20 +122,49 @@ func (a *agent) runContinuationWorker(ctx context.Context) {
} }
} }
// processContinuations dispatches each pending item as its OWN goroutine
// (safego.Go, so a panic deep in one task's resumed turn — JSON parsing of
// model output, an unexpected nil in a tool result — is recovered and logged
// instead of taking down this whole function, which used to run every
// item sequentially in the SAME goroutine as the ticker loop. Two problems
// that fixed: (1) throughput — task B's continuation no longer waits for
// task A's full (up to 10-minute) resumed turn to finish first, the exact
// per-task blocking this session's earlier concurrency work removed from the
// live-chat path but had left in place here; (2) survivability — since Go
// panics unwind the goroutine they occur in, an unrecovered one here used to
// mean this call (and every future tick, since the whole ticker loop runs in
// one goroutine) would simply stop — auto-continuation for every task would
// silently die until nomos restarted. Now a single bad item can only ever
// take down its own goroutine.
func (a *agent) processContinuations(ctx context.Context) { func (a *agent) processContinuations(ctx context.Context) {
pending := a.store.pendingContinuations(ctx, 5) pending := a.store.pendingContinuations(ctx, 5)
windowOpen := a.store.assentWindowActive(ctx, a.agentID)
for _, p := range pending { for _, p := range pending {
// Scope gate: only auto-continue while an approved plan is active. // Scope gate: only auto-continue while an approved plan is active FOR
// A finished one-off execution with no window is left as-is (marked // THIS SESSION. Checked per-item, not once for the whole batch — with
// continued so we don't re-check it forever) — the operator decides // multiple tasks in flight, one task's open window must never cover a
// what happens next, as today. // pending continuation belonging to a different task.
if !windowOpen { if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) {
// Re-open the assent window if this session is genuinely
// executing (plan was approved, work is in progress) — the
// window may have expired while the execution ran. Don't
// penalize timing: the plan was approved, the work happened,
// the result should flow back.
sesh, seshErr := a.store.getSession(ctx, p.SessionID)
if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") {
a.openAssentWindow(ctx, p.SessionID)
slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID)
} else {
// Genuinely no plan — inject a visible note so the
// operator knows WHY the agent didn't auto-continue.
note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status)
body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true})
a.store.saveMessage(context.Background(), p.SessionID, "assistant", body)
a.store.markContinued(ctx, p.ExecID) a.store.markContinued(ctx, p.ExecID)
continue continue
} }
}
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
a.continueSession(ctx, p) safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
} }
} }
@@ -82,17 +178,24 @@ func (a *agent) processContinuations(ctx context.Context) {
// complaint this exists to fix — polling alone only helps if there's // complaint this exists to fix — polling alone only helps if there's
// something new to poll for. // something new to poll for.
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
note := buildContinuationNote(p)
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
a.resumeSession(ctx, p.SessionID, buildContinuationNote(p))
}
// resumeSession re-invokes the agent for a session with a system-injected note —
// a finished execution (continueSession) or an operator's answer to a question
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
// in place as each tool call lands) so the frontend poller sees each step,
// instead of total silence until the whole resume concludes.
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
placeholder, _ := json.Marshal(map[string]any{ placeholder, _ := json.Marshal(map[string]any{
"role": "assistant", "role": "assistant",
"text": "", "text": "",
"auto": true, "auto": true,
}) })
msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder) msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder)
if err != nil { if err != nil {
slog.Error("nomos: continuation placeholder insert failed", "session", p.SessionID, "error", err) slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err)
} }
var toolCalls []map[string]any var toolCalls []map[string]any
@@ -124,34 +227,94 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
// without this outer retry the operator would see nothing at all. // without this outer retry the operator would see nothing at all.
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
for attempt := 0; attempt < 2; attempt++ { // B.3: escalate the recovery note across attempts — a transient flake
// needs a different prompt than a model that's stuck no-op'ing. The
// final attempt is maximally directive ("do this specific thing now").
// B.5: back off between retries (4s, 8s) so a transient provider issue
// has time to clear — 3 identical calls in 3 seconds just get 3
// identical empties.
notes := []string{
note, // attempt 0: the original (already enriched per B.2) note
fmt.Sprintf("[System: your previous turn produced no response. %s. Produce a response now — call the next tool or report progress in one sentence.]", note),
fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. The next action is: pick the lowest-pending plan step, mark it running with update_plan_step, and call run for its target. Do that now.]"),
}
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-cctx.Done():
return
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
}
}
toolCalls, finalText, errText = nil, "", "" toolCalls, finalText, errText = nil, "", ""
// P3: accumulate per-iteration reasoning instead of overwriting
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
var textParts []string
emit := func(ev agentEvent) { emit := func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" { if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok { if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type m["type"] = ev.Type
// One entry per tool call: tool_use creates it,
// tool_result merges the result into the same entry
// (matched by id). Before this fix, both events
// appended separate entries, doubling every tool call.
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m) toolCalls = append(toolCalls, m)
} }
}
persist() // live: a poller sees this step land within seconds persist() // live: a poller sees this step land within seconds
} }
if ev.Type == "text" { if ev.Type == "text" {
finalText, _ = ev.Data.(string) if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
persist()
}
} }
if ev.Type == "error" { if ev.Type == "error" {
errText, _ = ev.Data.(string) errText, _ = ev.Data.(string)
} }
} }
a.chatWith(cctx, p.SessionID, "", note, emit) a.chatWith(cctx, sessionID, "", notes[attempt], emit)
if finalText != "" || len(toolCalls) > 0 { if finalText != "" || len(toolCalls) > 0 {
break break
} }
if attempt == 0 { if attempt < 2 {
slog.Warn("nomos: auto-continuation produced nothing, retrying once", "session", p.SessionID, "execution", p.ExecID, "error", errText) slog.Warn("nomos: resume produced nothing, retrying", "session", sessionID, "error", errText, "attempt", attempt+1)
} }
} }
if errText != "" && finalText == "" { if errText != "" && finalText == "" {
slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText) slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
// Persist a visible system note in the transcript so the
// operator sees what happened, but do NOT auto-complete the
// task — leave it in 'executing' so a follow-up chat message
// can resume it. Before this fix, the task was marked 'failed'
// here, which ended it permanently and required starting over.
resumeFailedNote := fmt.Sprintf("[System: auto-resume failed after retrying: %s. The task is paused — send another message to continue.]", errText)
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": resumeFailedNote,
"auto": true,
})
if msgID != uuid.Nil {
a.store.updateMessage(context.Background(), msgID, body)
} else {
// No placeholder was inserted (rare), save directly.
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
}
return // do not call persist() again — already persisted above
} }
persist() // final state — same row, updated one last time with the concluding text persist() // final state — same row, updated one last time with the concluding text
} }

370
cmd/nomos/eval/main.go Normal file
View File

@@ -0,0 +1,370 @@
// Command nomos-eval runs golden conversation evals against a live nomos
// gateway. It loads a YAML manifest of conversations + assertions, sends
// each prompt to the chat endpoint, waits for the turn(s) to finish, and
// scores assertions against the persisted transcript.
//
// Usage:
//
// go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
//
// The gateway must already be running (nomos serve, or the docker container).
// Each conversation costs real OpenRouter credits (~$0.010.05 each).
//
// Manifest format — see evals/example.yaml. Assertions are scored against the
// final transcript: tool calls made, plan steps, final session status, and
// whether the turn completed. The runner does NOT judge text quality — only
// structural properties that can be checked deterministically from the
// persisted state. This is deliberate: text quality is model-dependent and
// noisy; structure is what the Go gates + SOUL.md should enforce.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
func main() {
gateway := flag.String("gateway", "http://localhost:8092", "nomos gateway URL")
manifestGlob := flag.String("manifest", "evals/*.yaml", "glob of manifest files to run")
timeout := flag.Duration("timeout", 2*time.Minute, "per-conversation timeout")
flag.Parse()
if err := health(*gateway); err != nil {
fmt.Fprintf(os.Stderr, "gateway not reachable at %s: %v\n", *gateway, err)
os.Exit(1)
}
files, err := filepath.Glob(*manifestGlob)
if err != nil {
fmt.Fprintf(os.Stderr, "glob %s: %v\n", *manifestGlob, err)
os.Exit(1)
}
if len(files) == 0 {
fmt.Fprintf(os.Stderr, "no manifests matched %s\n", *manifestGlob)
os.Exit(1)
}
total, passed, failed := 0, 0, 0
for _, f := range files {
convs, err := loadManifest(f)
if err != nil {
fmt.Fprintf(os.Stderr, "load %s: %v\n", f, err)
os.Exit(1)
}
for _, c := range convs {
total++
name := c.Name
if name == "" {
name = fmt.Sprintf("conversation-%d", total)
}
fmt.Printf("=== %s (from %s) ===\n", name, filepath.Base(f))
res := runConversation(context.Background(), *gateway, c, *timeout)
if res.Passed {
passed++
fmt.Printf(" ✅ PASS (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
} else {
failed++
fmt.Printf(" ❌ FAIL (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
}
for _, a := range res.Assertions {
mark := "✅"
if !a.Passed {
mark = "❌"
}
fmt.Printf(" %s %s: %s\n", mark, a.Name, a.Detail)
}
}
}
fmt.Printf("\n=== Summary: %d/%d passed, %d failed ===\n", passed, total, failed)
if failed > 0 {
os.Exit(1)
}
}
func health(gateway string) error {
resp, err := http.Get(gateway + "/healthz")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("healthz status %d", resp.StatusCode)
}
return nil
}
// runConversation sends the prompt (and any followup), waits for each turn to
// finish, then scores assertions against the final transcript.
func runConversation(ctx context.Context, gateway string, c conversation, timeout time.Duration) convResult {
start := time.Now()
deadline := time.Now().Add(timeout)
res := convResult{}
// Send the initial prompt (no session_id → creates a new session).
sid, err := sendChat(ctx, gateway, "", c.Prompt)
if err != nil {
res.Assertions = []assertionResult{{Name: "send_prompt", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
res.SessionID = sid
// Wait for the first turn to finish.
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
res.Assertions = []assertionResult{{Name: "turn_complete", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
// Send followup if any.
for _, fu := range c.followups() {
if _, err := sendChat(ctx, gateway, sid, fu); err != nil {
res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
res.Assertions = []assertionResult{{Name: "followup_turn_complete", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
}
// Fetch the final transcript + session state.
transcript, session, err := fetchTranscript(ctx, gateway, sid)
if err != nil {
res.Assertions = []assertionResult{{Name: "fetch_transcript", Passed: false, Detail: err.Error()}}
res.Duration = time.Since(start)
return res
}
res.ToolCallCount = transcript.toolCallCount()
res.Duration = time.Since(start)
// Score assertions.
res.Assertions = scoreAssertions(c.Assertions, transcript, session)
res.Passed = true
for _, a := range res.Assertions {
if !a.Passed {
res.Passed = false
break
}
}
return res
}
// sendChat POSTs to /chat and extracts the session_id from the first SSE
// event, then KEEPS READING the stream until it ends (the `done` event or
// the connection closes). This is critical: the chat handler uses
// r.Context() which cancels when the HTTP connection closes — if we stop
// reading after the session event, the agent's work gets canceled mid-turn.
// We must drain the full stream so the agent completes its turn server-side.
func sendChat(ctx context.Context, gateway, sid, message string) (string, error) {
body, _ := json.Marshal(map[string]string{"session_id": sid, "message": message})
req, _ := http.NewRequestWithContext(ctx, "POST", gateway+"/chat", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 202 {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("chat status %d: %s", resp.StatusCode, string(b))
}
// For a reconnect (sid != ""), the body is 202 with no stream.
if sid != "" {
io.Copy(io.Discard, resp.Body)
return sid, nil
}
// Read the SSE stream, capturing the session_id from the first session
// event, and draining the rest so the agent's turn completes. The stream
// ends when the server closes it (after the `done` event) or when the
// request context cancels.
dec := newSSEReader(resp.Body)
sessionID := ""
for {
ev, err := dec.next()
if err != nil {
if sessionID == "" {
return "", fmt.Errorf("no session event before stream end: %w", err)
}
return sessionID, nil
}
if ev["type"] == "session" && sessionID == "" {
if s, ok := ev["session_id"].(string); ok {
sessionID = s
}
}
// Keep reading until the stream ends — don't return early.
}
}
// waitForTurn polls the session until its last_active_at stops advancing for
// 8 seconds (the turn ended) or the session reaches a terminal status. We
// can't rely on status=done alone because a trivial task may auto-complete
// while a plan-proposing task stays in 'executing' waiting for approval.
func waitForTurn(ctx context.Context, gateway, sid string, deadline time.Time) error {
var lastActive string
stableSince := time.Now()
for {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for turn to complete")
}
_, session, err := fetchTranscript(ctx, gateway, sid)
if err != nil {
time.Sleep(2 * time.Second)
continue
}
if session.LastActive != lastActive {
lastActive = session.LastActive
stableSince = time.Now()
}
if time.Since(stableSince) >= 8*time.Second {
return nil // turn is idle — consider it complete
}
if session.Status == "done" || session.Status == "failed" {
return nil
}
time.Sleep(2 * time.Second)
}
}
type transcript struct {
Messages []struct {
Role string `json:"role"`
Content struct {
Text string `json:"text"`
ToolCalls []map[string]any `json:"tool_calls"`
} `json:"content"`
} `json:"messages"`
// PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations
// assertion). Each step carries a `generation` int; distinctGenerations
// counts the unique values. nil when the endpoint returned no plan
// (e.g. a pure-DB Q&A with no propose_plan call).
PlanSteps []planStep `json:"steps"`
}
// planStep is one step from /sessions/{id}/plan, carrying only the fields the
// eval needs: the generation number (P2 iteration counter).
type planStep struct {
Generation int `json:"generation"`
Status string `json:"status"`
Title string `json:"title"`
}
func (t transcript) toolCallCount() int {
n := 0
for _, m := range t.Messages {
n += len(m.Content.ToolCalls)
}
return n
}
func (t transcript) toolNames() []string {
var names []string
for _, m := range t.Messages {
for _, tc := range m.Content.ToolCalls {
if name, ok := tc["name"].(string); ok {
names = append(names, name)
}
}
}
return names
}
// distinctGenerations counts unique plan generation values across all plan
// steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0
// when there are no plan steps (no propose_plan was called).
func (t transcript) distinctGenerations() int {
seen := map[int]bool{}
for _, s := range t.PlanSteps {
seen[s.Generation] = true
}
return len(seen)
}
type sessionState struct {
ID string `json:"id"`
Status string `json:"status"`
Outcome string `json:"outcome"`
LastActive string `json:"last_active_at"`
}
// fetchTranscript fetches the messages from /sessions/{id} (which returns
// only session_id + messages) and the session metadata from /sessions
// (which returns status/outcome/last_active_at for each session). P5 also
// fetches /sessions/{id}/plan for the plan_generations assertion.
func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) {
var t transcript
resp, err := http.Get(gateway + "/sessions/" + sid)
if err != nil {
return t, sessionState{}, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return t, sessionState{}, err
}
if err := json.Unmarshal(b, &t); err != nil {
return t, sessionState{}, err
}
// Fetch the plan (steps with generation numbers) for the
// plan_generations assertion. A 404 or empty response is fine — a
// pure-DB Q&A with no propose_plan has no plan.
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan"); perr == nil {
if planResp.StatusCode == 200 {
pb, _ := io.ReadAll(planResp.Body)
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
}
planResp.Body.Close()
}
// The detail endpoint doesn't return status/outcome — fetch from the
// sessions list and find the matching id.
s, err := fetchSessionMeta(ctx, gateway, sid)
return t, s, err
}
// fetchSessionMeta fetches /sessions and extracts the one matching sid.
func fetchSessionMeta(ctx context.Context, gateway, sid string) (sessionState, error) {
resp, err := http.Get(gateway + "/sessions")
if err != nil {
return sessionState{}, err
}
defer resp.Body.Close()
var list struct {
Sessions []sessionState `json:"sessions"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return sessionState{}, err
}
for _, s := range list.Sessions {
if s.ID == sid {
return s, nil
}
}
return sessionState{}, fmt.Errorf("session %s not found in list", sid)
}
// convResult is the outcome of one conversation.
type convResult struct {
SessionID string
Passed bool
Duration time.Duration
ToolCallCount int
Assertions []assertionResult
}
type assertionResult struct {
Name string
Passed bool
Detail string
}

236
cmd/nomos/eval/manifest.go Normal file
View File

@@ -0,0 +1,236 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// conversation is one golden conversation from a manifest.
type conversation struct {
Name string `yaml:"name"`
Prompt string `yaml:"prompt"`
Followup string `yaml:"followup"` // backward compat: single followup
Followups []string `yaml:"followups"` // P5: multi-turn followups
Assertions []assertion `yaml:"assertions"`
}
// followups returns the full list of follow-up messages, supporting both
// the single `followup` field (backward compat) and the multi-turn
// `followups` list.
func (c conversation) followups() []string {
if len(c.Followups) > 0 {
return c.Followups
}
if c.Followup != "" {
return []string{c.Followup}
}
return nil
}
// assertion is one check against the final transcript. The `kind` field
// selects the scorer; the rest are scorer-specific parameters.
//
// Supported kinds:
//
// completes — session status reached done/failed (not stuck executing)
// outcome_is — session outcome == value (success/failure/partial)
// no_propose_plan — propose_plan was never called
// proposes_plan — propose_plan called >= 1 time (plan-always model; P1)
// proposes_plan_once — propose_plan was called exactly once
// no_duplicate_proposal — propose_plan called at most once
// plan_before_run — the first `run` call comes after the first `propose_plan` (P1 ordering gate)
// plan_generations — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup)
// writes_back — update_entity_attributes or create_relationship was called
// max_tool_calls — total tool calls <= value
// max_run_calls — total `run` calls <= value
// no_run — `run` was never called
// calls_tool — the named tool appears in the transcript
// plan_step_count — the plan has exactly `value` steps
// no_duplicate_complete — complete_task called at most once
type assertion struct {
Kind string `yaml:"kind"`
Value any `yaml:"value"`
}
// loadManifest reads a YAML file containing a list of conversations.
func loadManifest(path string) ([]conversation, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var convs []conversation
if err := yaml.Unmarshal(b, &convs); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return convs, nil
}
// scoreAssertions evaluates each assertion against the transcript + session.
func scoreAssertions(asserts []assertion, t transcript, s sessionState) []assertionResult {
out := make([]assertionResult, 0, len(asserts))
for _, a := range asserts {
r := assertionResult{Name: a.Kind}
r.Passed, r.Detail = scoreOne(a, t, s)
if !r.Passed && r.Detail == "" {
r.Detail = "assertion failed"
}
out = append(out, r)
}
return out
}
func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
tools := t.toolNames()
switch a.Kind {
case "completes":
if s.Status == "done" || s.Status == "failed" {
return true, fmt.Sprintf("status=%s", s.Status)
}
return false, fmt.Sprintf("status=%s (not terminal)", s.Status)
case "outcome_is":
want, _ := a.Value.(string)
if s.Outcome == want {
return true, fmt.Sprintf("outcome=%s", s.Outcome)
}
return false, fmt.Sprintf("outcome=%s, want %s", s.Outcome, want)
case "no_propose_plan":
n := countTool(tools, "propose_plan")
if n == 0 {
return true, "propose_plan not called"
}
return false, fmt.Sprintf("propose_plan called %d time(s)", n)
case "proposes_plan":
// P1 plan-always: propose_plan called >= 1 time.
n := countTool(tools, "propose_plan")
if n >= 1 {
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
}
return false, "propose_plan never called (plan-always requires >= 1)"
case "proposes_plan_once":
n := countTool(tools, "propose_plan")
if n == 1 {
return true, "propose_plan called once"
}
return false, fmt.Sprintf("propose_plan called %d time(s), want 1", n)
case "no_duplicate_proposal":
n := countTool(tools, "propose_plan")
if n <= 1 {
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
}
return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n)
case "plan_before_run":
// P1 ordering gate: the first `run` call's global index in the
// transcript is strictly greater than the first `propose_plan`
// index. Both indices are over the flat tool-call list (across all
// messages, in order).
planIdx, runIdx := -1, -1
for i, name := range tools {
if name == "propose_plan" && planIdx == -1 {
planIdx = i
}
if name == "run" && runIdx == -1 {
runIdx = i
}
}
if runIdx == -1 {
return true, "run never called (ordering trivially satisfied)"
}
if planIdx == -1 {
return false, "run called but propose_plan never called"
}
if planIdx < runIdx {
return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx)
}
return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx)
case "plan_generations":
// P2 iteration: counts distinct `generation` values in
// session_plan_steps. 1 = single sub-task, 2 = one follow-up
// sub-task, etc. Requires the plan endpoint to return generation
// values; the eval fetches /sessions/{id}/plan and passes it via
// the transcript's PlanSteps field.
want := toInt(a.Value)
gens := t.distinctGenerations()
if gens == want {
return true, fmt.Sprintf("%d plan generation(s)", gens)
}
return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want)
case "writes_back":
n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship")
if n > 0 {
return true, fmt.Sprintf("%d writeback call(s)", n)
}
return false, "no update_entity_attributes or create_relationship calls"
case "max_tool_calls":
max := toInt(a.Value)
if t.toolCallCount() <= max {
return true, fmt.Sprintf("%d tool calls (<= %d)", t.toolCallCount(), max)
}
return false, fmt.Sprintf("%d tool calls, want <= %d", t.toolCallCount(), max)
case "max_run_calls":
max := toInt(a.Value)
n := countTool(tools, "run")
if n <= max {
return true, fmt.Sprintf("%d run calls (<= %d)", n, max)
}
return false, fmt.Sprintf("%d run calls, want <= %d", n, max)
case "no_run":
n := countTool(tools, "run")
if n == 0 {
return true, "run not called"
}
return false, fmt.Sprintf("run called %d time(s)", n)
case "calls_tool":
want, _ := a.Value.(string)
n := countTool(tools, want)
if n > 0 {
return true, fmt.Sprintf("%s called %d time(s)", want, n)
}
return false, fmt.Sprintf("%s not called", want)
case "no_duplicate_complete":
n := countTool(tools, "complete_task")
if n <= 1 {
return true, fmt.Sprintf("complete_task called %d time(s)", n)
}
return false, fmt.Sprintf("complete_task called %d time(s), want <= 1", n)
default:
return false, fmt.Sprintf("unknown assertion kind: %s", a.Kind)
}
}
func countTool(names []string, name string) int {
n := 0
for _, x := range names {
if x == name {
n++
}
}
return n
}
func toInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
}
return 0
}

52
cmd/nomos/eval/sse.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"bufio"
"encoding/json"
"io"
"strings"
)
// sseReader parses a text/event-stream into a sequence of JSON events.
// Each event is one or more "data: " lines; the lines are concatenated
// and parsed as a single JSON object. Blank lines separate events.
type sseReader struct {
r *bufio.Reader
}
func newSSEReader(r io.Reader) *sseReader {
return &sseReader{r: bufio.NewReader(r)}
}
func (s *sseReader) next() (map[string]any, error) {
var data strings.Builder
for {
line, err := s.r.ReadString('\n')
if err != nil {
if err == io.EOF && data.Len() > 0 {
return parseEvent(data.String())
}
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if data.Len() > 0 {
return parseEvent(data.String())
}
continue // blank line, no event buffered yet
}
if strings.HasPrefix(line, "data: ") {
data.WriteString(strings.TrimPrefix(line, "data: "))
} else if strings.HasPrefix(line, "data:") {
data.WriteString(strings.TrimPrefix(line, "data:"))
}
}
}
func parseEvent(s string) (map[string]any, error) {
var ev map[string]any
if err := json.Unmarshal([]byte(s), &ev); err != nil {
return nil, err
}
return ev, nil
}

View File

@@ -15,6 +15,9 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
) )
func main() { func main() {
@@ -26,6 +29,10 @@ func main() {
if mcpURL == "" { if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp" mcpURL = "http://localhost:8090/mcp"
} }
// api's combinedAuth requires a bearer token on every request (no
// dev-open bypass — plans/2026-07-12-wails-desktop-app.md 0.4); this is
// the same shared secret api validates against (OIKOS_MCP_BEARER_TOKEN).
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
agentSlug := os.Getenv("NOMOS_AGENT_SLUG") agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
if agentSlug == "" { if agentSlug == "" {
@@ -42,10 +49,19 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel() defer cancel()
client, err := newMCPClient(mcpURL) // One MCP client PER SESSION, not one shared client for the whole
if err != nil { // process — see mcpClientPool's doc comment. A dedicated client is
// created lazily on each session's first tool call.
clientPool := newMCPClientPool(mcpURL, mcpToken)
// Prove connectivity at startup the same way the old single-client
// constructor did, so a misconfigured/unreachable MCP endpoint still
// fails fast on boot instead of only on the first real chat. Doesn't
// reuse the pool (nothing to key it by yet) — just a throwaway probe.
if probe, err := newMCPClient(mcpURL, mcpToken); err != nil {
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err) slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1) os.Exit(1)
} else {
probe.close()
} }
st, err := newStore(ctx, databaseURL) st, err := newStore(ctx, databaseURL)
@@ -57,7 +73,7 @@ func main() {
defer st.close() defer st.close()
} }
nAgent, err := newAgent(ctx, client, st, agentSlug) nAgent, err := newAgent(ctx, clientPool, st, agentSlug)
if err != nil { if err != nil {
slog.Error("nomos: agent init", "error", err) slog.Error("nomos: agent init", "error", err)
os.Exit(1) os.Exit(1)
@@ -66,7 +82,40 @@ func main() {
// Event-driven auto-continuation: feed finished async executions back // Event-driven auto-continuation: feed finished async executions back
// into the agent so an approved plan runs to completion (and recovers // into the agent so an approved plan runs to completion (and recovers
// from failures) without the operator ticking it forward each step. // from failures) without the operator ticking it forward each step.
go nAgent.runContinuationWorker(ctx) safego.Go("nomos:continuation-worker", func() { nAgent.runContinuationWorker(ctx) })
// Idle sweep for stalled goal-bearing tasks (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md) — a coarser,
// slower-ticking counterpart to the continuation worker above.
safego.Go("nomos:idle-sweep-worker", func() { nAgent.runIdleSweepWorker(ctx) })
safego.Go("nomos:mcp-pool-sweeper", func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
clientPool.sweep()
}
}
})
// Stale execution sweep: cancels non-terminal executions older than
// 10 minutes (orphaned by MCP timeouts — see cleanupStaleExecutions).
safego.Go("nomos:stale-execution-sweeper", func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
st.cleanupStaleExecutions(ctx, 10*time.Minute)
}
}
})
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +123,7 @@ func main() {
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
handleQuery(w, r, client, agentSlug, mcpURL) handleQuery(w, r, clientPool, agentSlug, mcpURL)
}) })
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, nAgent, st) handleChat(w, r, nAgent, st)
@@ -83,7 +132,7 @@ func main() {
handleSessionsList(w, r, st) handleSessionsList(w, r, st)
}) })
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
handleSessionDetail(w, r, st) handleSessionDetail(w, r, st, nAgent)
}) })
addr := os.Getenv("NOMOS_LISTEN") addr := os.Getenv("NOMOS_LISTEN")
@@ -92,17 +141,17 @@ func main() {
} }
srv := &http.Server{Addr: addr, Handler: mux} srv := &http.Server{Addr: addr, Handler: mux}
go func() { safego.Go("nomos:http-server", func() {
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "") slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
if err := srv.ListenAndServe(); err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("nomos: serve", "error", err) slog.Error("nomos: serve", "error", err)
} }
}() })
<-ctx.Done() <-ctx.Done()
slog.Info("nomos: shutting down") slog.Info("nomos: shutting down")
srv.Shutdown(context.Background()) srv.Shutdown(context.Background())
client.close() clientPool.closeAll()
default: default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
@@ -130,11 +179,30 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
http.Error(w, "bad request: "+err.Error(), 400) http.Error(w, "bad request: "+err.Error(), 400)
return return
} }
if req.Message == "" { if req.Message == "" && req.SessionID == "" {
http.Error(w, "message is required", 400) http.Error(w, "message is required", 400)
return return
} }
// Empty message with an existing session = reconnect/resume. The
// frontend sends this after a dropped SSE stream to re-establish the
// connection and catch up on any auto-continuation work that happened
// while disconnected. Route into resumeSession so the agent sees a
// system note and reports current state.
if req.Message == "" && req.SessionID != "" {
slog.Info("nomos: reconnect", "session", req.SessionID)
safego.Go("nomos:reconnect:"+req.SessionID, func() {
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
note := st.enrichResumeNote(context.Background(), req.SessionID, base)
a.resumeSession(context.Background(), req.SessionID, note)
})
// Return 202 so the frontend doesn't try to consume an SSE stream
// from this POST — resumeSession writes to the DB directly and
// the poller (already running from handleDisconnect) picks it up.
w.WriteHeader(202)
return
}
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
http.Error(w, "streaming not supported", 500) http.Error(w, "streaming not supported", 500)
@@ -149,9 +217,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
ctx := r.Context() ctx := r.Context()
sessionID := req.SessionID sessionID := req.SessionID
// pctx (persistence context) is deliberately context.Background(), not
// ctx/r.Context(), for every DB write in this handler — ctx cancels the
// instant the client disconnects (Stop button, tab close, network blip),
// and a write made with an already-cancelled context fails. Before this
// fix, the assistant message was only ever saved ONCE, at the very end,
// using ctx — so a disconnect mid-turn silently lost the ENTIRE turn's
// tool-call history from the persisted transcript, even though real work
// (executions launched, knowledge written) had already happened
// server-side. The agent's own work (a.chat below) still correctly stops
// when ctx cancels — this only changes what happens to persistence.
pctx := context.Background()
if sessionID == "" { if sessionID == "" {
title := truncate(req.Message, 80) title := truncate(req.Message, 80)
sess, err := st.createSession(ctx, title) sess, err := st.createSession(pctx, title)
if err != nil { if err != nil {
slog.Error("nomos: create session", "error", err) slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral" sessionID = "ephemeral"
@@ -159,45 +239,122 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
sessionID = sess.ID sessionID = sess.ID
} }
} else { } else {
st.touchSession(ctx, sessionID) // P2 iteration: if the operator sends a follow-up on a session
// that already reached a terminal state (done/failed), reopen it
// so a new sub-task can be framed (set_goal → propose_plan →
// execute). reopenSession marks the prior plan's steps as
// `replaced` (proposePlan ignores those) and clears outcome/
// summary. Without this, propose_plan refuses the follow-up with
// errPlanInFlight because the prior steps are all `done`. If the
// session is still active, reopen is a no-op — the follow-up is
// just a continuation of in-flight work.
st.reopenSession(pctx, sessionID)
st.touchSession(pctx, sessionID)
} }
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100)) slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.saveMessage(ctx, sessionID, "user", userMsg) st.saveMessage(pctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its
// replayed history.
if qid := st.openQuestionID(pctx, sessionID); qid != "" {
st.answerQuestion(pctx, sessionID, qid, req.Message)
}
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
toolCalls := []map[string]any{} toolCalls := []map[string]any{}
// P3: accumulate per-iteration reasoning instead of overwriting with
// the final `text` event. The agent loop emits a `text` event for each
// LLM iteration that produced text (intermediate reasoning before tool
// calls + the final answer). Without accumulation, only the last `text`
// survives in the persisted row — a reload shows the final summary but
// not the thinking that led to each tool call.
var textParts []string
var finalText string var finalText string
// Incremental persistence, mirroring resumeSession's existing
// placeholder+update pattern (continue.go): insert a placeholder now,
// update the SAME row after every tool call, so whatever happened before
// an abort is never lost — only what hadn't happened yet is.
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
}
persist := func() {
if msgID == uuid.Nil {
return
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"tool_calls": toolCalls,
})
st.updateMessage(pctx, msgID, body)
}
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) { a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" { if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok { if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type m["type"] = ev.Type
// One entry per tool call: tool_use creates it, tool_result
// merges the result into the same entry (matched by id).
// Before this fix, both events appended separate entries,
// doubling every tool call in the persisted transcript
// (confirmed pre-existing in d9cdcee1, v0.3.x era).
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m) toolCalls = append(toolCalls, m)
} }
} }
persist() // live: survives even if the client disconnects right after
}
if ev.Type == "text" { if ev.Type == "text" {
finalText, _ = ev.Data.(string) // P3: accumulate. Each `text` event is one iteration's reasoning
// (or the final answer). Join with newlines so the persisted row
// reads as the full transcript of what the agent said, not just
// the last thing.
if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
persist()
}
} }
sseEvent(w, flusher, ev) sseEvent(w, flusher, ev)
}) })
assistantMsg, _ := json.Marshal(map[string]any{ // B.6: if the turn ended with no text and no tool calls (the model
"role": "assistant", // empty-response'd and all retries failed), delete the placeholder row
"text": finalText, // instead of persisting an empty bubble. The error event was already
"tool_calls": toolCalls, // streamed to the frontend via the 'done with error=true' event, so the
}) // operator sees the error inline — an empty assistant bubble in the
st.saveMessage(ctx, sessionID, "assistant", assistantMsg) // transcript adds nothing and looks like the agent is broken.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
st.deleteMessage(pctx, msgID)
} else {
persist() // final state — same row, updated one last time with the concluding text
}
// Generate a meaningful title from the assistant's first answer // Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session. // instead of reusing the raw user message for every session.
if finalText != "" && sessionID != "ephemeral" { if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80) title := truncate(finalText, 80)
if title != "" { if title != "" {
st.updateSessionTitle(ctx, sessionID, title) st.updateSessionTitle(pctx, sessionID, title)
} }
} }
} }
@@ -222,18 +379,66 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions}) json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
} }
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) { func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
if st == nil { if st == nil {
http.Error(w, "not found", 404) http.Error(w, "not found", 404)
return return
} }
id := strings.TrimPrefix(r.URL.Path, "/sessions/") rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
parts := strings.Split(rest, "/")
id := parts[0]
if id == "" { if id == "" {
http.Error(w, "session id required", 400) http.Error(w, "session id required", 400)
return return
} }
// POST /sessions/{id}/questions/{qid}/answer — the operator answers a
// pinned question from the context panel; resume the agent with the answer.
if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
handleAnswerQuestion(w, r, st, a, id, parts[2])
return
}
// POST /sessions/{id}/resume — the operator asks the agent to continue.
if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost {
base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]"
note := st.enrichResumeNote(context.Background(), id, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) })
w.WriteHeader(202)
return
}
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
steps, err := st.getPlanSteps(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
}
}
switch r.Method { switch r.Method {
case http.MethodDelete: case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil { if err := st.deleteSession(r.Context(), id); err != nil {
@@ -256,7 +461,32 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
} }
} }
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { // handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) {
var req struct {
Answer string `json:"answer"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" {
http.Error(w, "answer is required", 400)
return
}
prompt, _, _ := st.getQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
note := st.enrichResumeNote(context.Background(), sessionID, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
}
w.WriteHeader(202)
}
func handleQuery(w http.ResponseWriter, r *http.Request, pool *mcpClientPool, agentSlug, mcpURL string) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405) http.Error(w, "method not allowed", 405)
return return
@@ -272,6 +502,16 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen
return return
} }
// The structured /query endpoint is stateless/session-less — "query" is a
// fixed pool key (not a real session id) so repeated calls reuse one
// dedicated connection instead of paying a fresh MCP handshake every time,
// while still never sharing a connection with an actual chat task.
client, err := pool.get("query")
if err != nil {
http.Error(w, "mcp unavailable: "+err.Error(), 502)
return
}
start := time.Now() start := time.Now()
if req.Tool != "" { if req.Tool != "" {
@@ -342,15 +582,29 @@ func truncate(s string, n int) string {
type mcpClient struct { type mcpClient struct {
baseURL string baseURL string
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
sessionID string sessionID string
http *http.Client http *http.Client
nextID int nextID int
mu sync.Mutex // MCP is one stateful session; serialize concurrent calls mu sync.Mutex // this client is one stateful MCP session; serialize ITS OWN calls
// toolsCache holds the last tools/list result. The tool list is static
// for the lifetime of one MCP connection — it only changes when the api
// process (re)registers tools, i.e. on a restart, which this client
// already detects and reacts to via reconnectLocked. Without this,
// buildTools (called at the start of EVERY chat turn, including every
// auto-continuation resume) paid a full tools/list round-trip every
// single time for a list that's almost always identical to the last one.
// Guarded separately from mu (not reused) so a cache check never
// contends with an in-flight doRequest call for a different method.
toolsMu sync.Mutex
toolsCache []toolDef
} }
func newMCPClient(baseURL string) (*mcpClient, error) { func newMCPClient(baseURL, token string) (*mcpClient, error) {
c := &mcpClient{ c := &mcpClient{
baseURL: baseURL, baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 30 * time.Second}, http: &http.Client{Timeout: 30 * time.Second},
} }
@@ -405,6 +659,12 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu. // reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
func (c *mcpClient) reconnectLocked() error { func (c *mcpClient) reconnectLocked() error {
c.sessionID = "" c.sessionID = ""
// A reconnect means the api process was restarted (or forgot us) — its
// tool registration may have changed, so the cached list is no longer
// trustworthy.
c.toolsMu.Lock()
c.toolsCache = nil
c.toolsMu.Unlock()
resp, err := c.send("initialize", map[string]any{ resp, err := c.send("initialize", map[string]any{
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": map[string]any{}, "capabilities": map[string]any{},
@@ -441,6 +701,9 @@ func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCRespo
if c.sessionID != "" { if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID) req.Header.Set("Mcp-Session-Id", c.sessionID)
} }
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
@@ -545,3 +808,107 @@ func (c *mcpClient) listTools() ([]string, error) {
func (c *mcpClient) close() { func (c *mcpClient) close() {
} }
// ─── Per-session MCP client pool ────────────────────────────────────────
//
// A single shared mcpClient serializes EVERY tool call across EVERY
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
// executes its SSH command synchronously inside that lock and is capped at
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
// calls, even trivial reads, behind it. The MCP *server* has no per-
// connection state to protect (newServer in internal/mcp/server.go returns
// one shared *mcp.Server instance whose tool handlers close only over the DB
// pool, which is already safe for concurrent use) — the mutex existed purely
// because the *client* reused one stateful transport session, not because
// the server needed it. Giving each task's own session its own client
// removes the cross-task serialization entirely: a task's own tool calls
// stay sequential (which they already are — the agent loop calls tools one
// at a time within a turn), but no longer block anyone else's.
type mcpClientPool struct {
baseURL string
token string
mu sync.Mutex
clients map[string]*pooledMCPClient
}
type pooledMCPClient struct {
client *mcpClient
lastUsed time.Time
}
func newMCPClientPool(baseURL, token string) *mcpClientPool {
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
}
// get returns the client for sessionID, creating and initializing one (a
// real MCP handshake) on first use. Session ids that don't identify a real
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
// the structured /query endpoint) still get exactly one dedicated,
// reused client each via the same map — just keyed on a fixed string instead
// of a real session id — so that traffic doesn't pay a fresh handshake per
// request while still never sharing a connection with an actual task.
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
key := sessionID
if key == "" {
key = "ephemeral"
}
p.mu.Lock()
if pc, ok := p.clients[key]; ok {
pc.lastUsed = time.Now()
p.mu.Unlock()
return pc.client, nil
}
p.mu.Unlock()
// Initialize outside the lock — it's a network round-trip, and holding
// the pool mutex for it would serialize unrelated sessions' first calls
// behind each other, undermining the whole point of this pool.
c, err := newMCPClient(p.baseURL, p.token)
if err != nil {
return nil, err
}
p.mu.Lock()
// Another goroutine may have created one for the same key while we were
// initializing (two of this session's tool calls racing on a cold
// start); keep whichever won, close out the loser's connection (a no-op
// today, but future-proof if mcpClient.close ever does real teardown).
if existing, ok := p.clients[key]; ok {
p.mu.Unlock()
c.close()
return existing.client, nil
}
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
p.mu.Unlock()
return c, nil
}
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
// before eviction — long enough to outlive a single slow `run` (capped at 10
// minutes server-side) plus normal think-time between a task's tool calls,
// short enough not to accumulate one abandoned connection per finished task
// forever.
const mcpClientIdleTimeout = 20 * time.Minute
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
func (p *mcpClientPool) sweep() {
cutoff := time.Now().Add(-mcpClientIdleTimeout)
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
if pc.lastUsed.Before(cutoff) {
pc.client.close()
delete(p.clients, key)
}
}
}
func (p *mcpClientPool) closeAll() {
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
pc.client.close()
delete(p.clients, key)
}
}

File diff suppressed because it is too large Load Diff

313
cmd/nomos/store_test.go Normal file
View File

@@ -0,0 +1,313 @@
package main
// Integration tests against a real Postgres, mirroring
// internal/db/integration_test.go's pattern: guarded by
// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run,
// full migrations applied, dropped on cleanup. Run with:
//
// docker compose up -d postgres
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/
import (
"context"
"errors"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// newTestStore creates a throwaway, fully-migrated database and returns a
// *store connected to it, cleaned up (including a matching task:<session>
// entity type in the ontology, needed by createTaskEntity/proposePlan tests)
// via t.Cleanup.
func newTestStore(t *testing.T) *store {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
testURL := swapTestDatabase(baseURL, dbName)
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
// session_plan_steps/session_questions tests don't need the ontology
// seed, but createTaskEntity's INSERT INTO entities (type='task') has an
// FK to entity_types — seed the minimal rows it needs directly rather
// than pulling in the full seeds/ontology.yaml ingest path.
if _, err := pool.Exec(ctx, `
INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta')
ON CONFLICT DO NOTHING;
INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition')
ON CONFLICT DO NOTHING;`); err != nil {
t.Fatalf("seed minimal ontology: %v", err)
}
return &store{pool: pool.Pool}
}
func swapTestDatabase(url, dbName string) string {
qi := strings.Index(url, "?")
params, base := "", url
if qi >= 0 {
params = url[qi:]
base = url[:qi]
}
si := strings.LastIndex(base, "/")
return base[:si+1] + dbName + params
}
// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a
// session's ENTIRE history on every turn with no bound. getRecentMessages
// caps that; this test checks both sides — under the limit, nothing is
// dropped and truncated=false; over it, only the most recent `limit` come
// back, in chronological order, with truncated=true.
func TestGetRecentMessages_Truncation(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "history window test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
const total = 35
const limit = 30
for i := 0; i < total; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil {
t.Fatalf("saveMessage %d: %v", i, err)
}
}
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages: %v", err)
}
if !truncated {
t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit)
}
if len(msgs) != limit {
t.Fatalf("got %d messages, want %d", len(msgs), limit)
}
// Chronological order: the oldest of the RETAINED messages should be the
// (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and
// the last should be the most recently saved (msg-34).
wantFirst := fmt.Sprintf("msg-%d", total-limit)
wantLast := fmt.Sprintf("msg-%d", total-1)
if got := extractText(msgs[0].Content); got != wantFirst {
t.Errorf("first retained message = %q, want %q", got, wantFirst)
}
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast {
t.Errorf("last retained message = %q, want %q", got, wantLast)
}
// Under the limit: nothing dropped.
sess2, err := s.createSession(ctx, "small session")
if err != nil {
t.Fatalf("createSession: %v", err)
}
for i := 0; i < 5; i++ {
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil {
t.Fatalf("saveMessage: %v", err)
}
}
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages (small): %v", err)
}
if truncated2 {
t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit)
}
if len(msgs2) != 5 {
t.Errorf("got %d messages, want 5", len(msgs2))
}
}
// TestProposePlan_RefuseInFlight is the concrete proof for the plan-drift
// fix (2026-07-14, "plan added twice in the sidebar"): proposePlan must
// REPLACE the step list only while every existing step is still 'pending'
// (a genuine pre-execution revision), and REFUSE the call once any step has
// started. The prior append-mode safety net (commit 5384499) preserved
// history but duplicated the plan in the sidebar when the agent re-proposed
// on "proceed". Refusing is the correct default — the agent must advance
// with update_plan_step + run.
func TestProposePlan_RefuseInFlight(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "plan refuse test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace).
out1, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step A"}})
if err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
if len(out1) != 1 || out1[0]["seq"] != 1 {
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
}
if out1[0]["generation"] != 1 {
t.Fatalf("proposePlan #1 generation = %v, want 1", out1[0]["generation"])
}
// Mark step 1 as started.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err)
}
// Second call, simulating a model that re-proposes mid-flight (the
// operator-reported "proceed" bug): since step 1 has left 'pending',
// this MUST refuse with errPlanInFlight, not append or replace.
_, err = s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
if !errors.Is(err, errPlanInFlight) {
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err)
}
// The original step 1 must be untouched — not erased, not appended to.
steps, err := s.getPlanSteps(ctx, sess.ID)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(steps) != 1 {
t.Fatalf("got %d persisted steps, want 1 (refused call must not mutate the plan)", len(steps))
}
if steps[0].Title != "Step A" || steps[0].Status != "running" {
t.Errorf("step 1 = %+v, want Step A still running (refused call must not touch it)", steps[0])
}
// Third call BEFORE anything runs on a fresh session: every step is
// still pending, so this must REPLACE, not refuse.
sess2, err := s.createSession(ctx, "plan replace test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Original"}}); err != nil {
t.Fatalf("proposePlan (initial): %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
t.Fatalf("proposePlan (revise before execution): %v", err)
}
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not refuse)", revisedSteps)
}
if revisedSteps[0].Generation != 1 {
t.Fatalf("revised step generation = %d, want 1 (fresh-start after DELETE resets generation)", revisedSteps[0].Generation)
}
}
// TestHadDiscoveryAndWriteback is the store-level proof for D.1 (refuse
// complete_task when discovery ran without writeback). hadDiscovery must
// report true only after a successful `run` call; hadEntityWriteback must
// report true only after a successful update_entity_attributes or
// create_relationship call. The D.1 gate in tasks.go combines these: refuse
// success when hadDiscovery && !hadEntityWriteback.
func TestHadDiscoveryAndWriteback(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// Before any tool calls: no discovery, no writeback.
if s.hadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = true before any tool calls, want false")
}
if s.hadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true before any tool calls, want false")
}
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
agentID := uuid.New()
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1")
if !s.hadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = false after a successful run call, want true")
}
if s.hadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true after only a run call, want false")
}
// A failed run call should NOT count as discovery (no facts learned).
sess2, err := s.createSession(ctx, "failed discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2")
if s.hadDiscovery(ctx, sess2.ID) {
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
}
// A get_entity call should NOT count as discovery (DB lookup, not live state).
sess3, err := s.createSession(ctx, "lookup test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3")
if s.hadDiscovery(ctx, sess3.ID) {
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
}
// update_entity_attributes sets hadEntityWriteback.
sess4, err := s.createSession(ctx, "writeback test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4")
if !s.hadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
}
// And the discovery+writeback combination (the conv3 scenario).
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5")
if !s.hadDiscovery(ctx, sess4.ID) {
t.Fatal("hadDiscovery = false after run+writeback, want true")
}
if !s.hadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
}
}

416
cmd/nomos/tasks.go Normal file
View File

@@ -0,0 +1,416 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
)
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
// shared MCP server (api:8090/mcp) has no session id — so these are handled
// in-process by nomos, which knows the session/task and holds the store.
// buildTools appends these to the model's tool list; the agent loop routes a
// call whose name isTaskTool to handleTaskTool instead of the MCP client.
//
// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step /
// ask_operator land in later phases through the same mechanism.
func taskToolDefs() []toolDef {
return []toolDef{
{
Name: "set_goal",
Description: "State the goal of this task in one sentence, as early as you " +
"can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " +
"as an LXC on strong'); it heads the task on the board and the context " +
"panel. Call it once you understand what the operator wants.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."},
},
"required": []string{"goal"},
},
},
{
Name: "propose_plan",
Description: "Propose the full ordered plan for this task. Call ONCE, before any " +
"execution, with EVERY step end-to-end (not one step at a time). FIRST step: " +
"research (prior knowledge, relations, blast radius). If your plan runs `run` " +
"against any target, include a LAST step: write back " +
"(update_entity_attributes + create_relationship + upsert_knowledge) — if you " +
"omit it, one is auto-appended. After this call: STOP and wait for operator " +
"approval (approval vocabulary: approved, yes, go, proceed, continue, ok, " +
"go ahead). Once a step has started (running/done/...), this tool REFUSES " +
"further calls — advance with update_plan_step + run instead. Re-propose only " +
"if the operator explicitly asks you to revise the whole plan. complete_task " +
"with outcome=success is REFUSED if you ran `run` but didn't call " +
"update_entity_attributes/create_relationship — write back before completing.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"steps": map[string]any{
"type": "array",
"description": "Ordered steps, first to last.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."},
"detail": map[string]any{"type": "string", "description": "Optional one-line detail."},
"target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."},
},
"required": []string{"title"},
},
},
},
"required": []string{"steps"},
},
},
{
Name: "update_plan_step",
Description: "Advance a plan step as you work it. Set status to 'running' when " +
"you start it (pass execution_id if the step queued a gated action, so " +
"the board can auto-close it when that finishes), then 'done' / 'failed' " +
"/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " +
"view honest.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."},
"status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."},
"execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."},
},
"required": []string{"seq", "status"},
},
},
{
Name: "ask_operator",
Description: "Ask the operator a question when you hit a real decision only " +
"they can make — an ambiguous target, a trade-off, missing information, " +
"or a destructive choice not already approved. This pins a structured " +
"question card in the context panel (with your options and the entities " +
"involved) and PAUSES the task until they answer; their answer resumes " +
"you automatically. Do NOT use it for things you can determine yourself " +
"with tools — only for genuine decisions.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string", "description": "The question, stated plainly."},
"why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."},
"options": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "The choices, if it's a pick-one decision.",
},
"context_entities": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "Entity slugs relevant to the decision (shown as chips).",
},
},
"required": []string{"prompt"},
},
},
{
Name: "complete_task",
Description: "Mark the current task finished. Call this once the goal is " +
"verified done — or when you've genuinely failed or only partially " +
"succeeded. Sets the task's outcome and a one-line summary shown on the " +
"task board. Record what you learned with upsert_knowledge BEFORE " +
"completing, so future tasks on the same entities benefit.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"outcome": map[string]any{
"type": "string",
"enum": []string{"success", "failure", "partial"},
"description": "Did the task achieve its goal?",
},
"summary": map[string]any{
"type": "string",
"description": "One line describing the result (shown on the task card).",
},
},
"required": []string{"outcome", "summary"},
},
},
}
}
// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int.
func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
// toStringSlice coerces a JSON tool-arg array to a non-empty []string.
func toStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it
// handled the call, or (nil, false) if name is not a local task tool (so the
// caller forwards it to the MCP client).
func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) {
switch name {
case "set_goal":
goal, _ := args["goal"].(string)
if strings.TrimSpace(goal) == "" {
return "error: set_goal needs a goal", true
}
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
return fmt.Sprintf("error setting goal: %v", err), true
}
// P1: the plan window is NOT opened here. Opening it on set_goal
// meant any config_mutation `run` auto-executed with zero operator
// approval, before a plan was even proposed (let alone approved) —
// a safety regression confirmed live in session d0d562e0. The
// window is now opened only when the operator approves a plan
// (chat-assent grant or explicit approval in agent.go), which is
// what the SOUL.md "approve the plan, not each step" model actually
// describes. set_goal records the goal + flips status to executing
// and nothing more.
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval.", true
case "propose_plan":
raw, _ := args["steps"].([]any)
var steps []planStepInput
for _, r := range raw {
m, ok := r.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
if strings.TrimSpace(title) == "" {
continue
}
detail, _ := m["detail"].(string)
target, _ := m["target_slug"].(string)
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target})
}
if len(steps) == 0 {
return "error: propose_plan needs at least one step with a title", true
}
// D.2: auto-append a writeback step if the agent didn't include one.
// The agent consistently writes vague last steps ("record findings")
// and then skips update_entity_attributes entirely (the #1 cause of
// knowledge-graph drift). Appending an explicit writeback step makes
// the seq-order enforcement (5.6) require it to be completed last,
// and D.1's complete_task gate enforces the actual calls. Together
// they close the loop structurally — neither relies on the agent
// reading SOUL.md.
hasWritebackStep := false
for _, st := range steps {
if strings.Contains(st.Title, "update_entity_attributes") ||
strings.Contains(st.Title, "create_relationship") ||
strings.Contains(st.Detail, "update_entity_attributes") ||
strings.Contains(st.Detail, "create_relationship") {
hasWritebackStep = true
break
}
}
appendedNote := ""
if !hasWritebackStep {
steps = append(steps, planStepInput{
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
})
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
}
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
if err != nil {
if errors.Is(err, errPlanInFlight) {
// The plan is already in flight — refuse the re-proposal.
// The agent must advance the existing plan with
// update_plan_step + run. This is the structural fix for
// the "plan added twice" sidebar drift the operator
// reported: instead of appending (which duplicated) or
// wiping (which lost progress), we refuse and direct.
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true
}
return fmt.Sprintf("error proposing plan: %v", err), true
}
// The writeback step is now always present (D.2 auto-appends it if
// the agent forgot), so the old advisory nudge is replaced by the
// structural gate: D.1 refuses complete_task without the actual
// update_entity_attributes/create_relationship calls.
result := fmt.Sprintf("Plan set (%d steps)%s. If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), appendedNote)
return result, true
case "update_plan_step":
seq := toInt(args["seq"])
status, _ := args["status"].(string)
execID, _ := args["execution_id"].(string)
if seq <= 0 || status == "" {
return "error: update_plan_step needs seq (>=1) and status", true
}
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
return fmt.Sprintf("error updating step %d: %v", seq, err), true
}
return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true
case "ask_operator":
prompt, _ := args["prompt"].(string)
if strings.TrimSpace(prompt) == "" {
return "error: ask_operator needs a prompt", true
}
qctx := map[string]any{}
if why, _ := args["why"].(string); strings.TrimSpace(why) != "" {
qctx["why"] = why
}
if opts := toStringSlice(args["options"]); len(opts) > 0 {
qctx["options"] = opts
}
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
qctx["entities"] = ents
}
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil {
return fmt.Sprintf("error posting question: %v", err), true
}
return "Question posted to the operator; the task is paused until they answer. " +
"Do not continue or call more tools — end your turn now and wait for their answer.", true
case "complete_task":
outcome, _ := args["outcome"].(string)
summary, _ := args["summary"].(string)
switch outcome {
case "":
outcome = "success" // no outcome given at all — assume success, the common case
case "success", "failure", "partial":
// valid, use as-is
default:
// The tool schema declares an enum, but a weaker model (or a
// typo) can still send anything — an unrecognized value used to
// persist as-is, silently, with only "failure" special-cased
// (store.completeTask derives status='failed' from it; anything
// else became status='done' regardless of what the value
// actually said). Default to "partial" rather than silently
// treating an unrecognized value as "success" — safer to
// under-claim than over-claim a task's outcome.
slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial",
"session", sessionID, "outcome", outcome)
outcome = "partial"
}
// D.1: refuse success when discovery ran but no writeback followed.
// The prior advisory warning (below) was ignorable — the agent
// saw it and ended the task anyway. This gate fires BEFORE
// completeTask runs, so the session stays in 'executing' state
// and the agent must call update_entity_attributes/create_relationship
// then retry complete_task. Only blocks `success`; an explicit
// `failure` or `partial` is allowed through (the agent is
// acknowledging it didn't finish — no reason to force writeback).
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
}
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
if errors.Is(err, errTaskAlreadyComplete) {
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
}
return fmt.Sprintf("error completing task: %v", err), true
}
result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
if !a.store.hadEntityWriteback(ctx, sessionID) {
result += "\n\n⚠ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
}
return result, true
default:
return nil, false
}
}
// autoCompleteTrivialTask is the case-1 fix from
// plans/2026-07-11-task-completion-safety-net.md: a session that never
// called set_goal never framed itself as a structured task, so a turn that
// ends with a plain-text answer and no further tool calls IS the task
// ending — but the model consistently skips complete_task for exactly this
// case (confirmed live: 43/50 production sessions were a single trivial
// Q&A exchange, none of which ever reached a terminal status). Rather than
// leave agent_sessions.status stuck at its creation-time default forever,
// close it out mechanically here: no judgment call needed, since SOUL.md
// already treats a one-shot answered question as done by definition.
func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) {
summary := strings.TrimSpace(responseText)
summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line
const maxLen = 120
if len(summary) > maxLen {
summary = summary[:maxLen] + "…"
}
if summary == "" {
summary = "Answered without further action needed."
}
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil {
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
}
}
// autoCompleteIfPlanDone is the structural safety net for "the agent did the
// work but forgot to call complete_task" — the #1 remaining model reliability
// gap after D.1's writeback gate. After a turn ends, if the session has a goal,
// the agent never called complete_task this turn, and either (a) all plan
// steps are terminal OR (b) the agent did discovery (ran `run`), auto-complete.
// Path (b) catches the common case where the agent skips update_plan_step
// bookkeeping but still does the actual work — the D.1 gate already enforces
// writeback before `complete_task`, so if the agent forgot to complete at all,
// we close it out mechanically. If writeback happened → success; if not →
// partial (honest: work was done but knowledge graph wasn't updated).
func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) {
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
return
}
sess, err := a.store.getSession(ctx, sessionID)
if err != nil || sess.Status != "executing" {
return
}
// Don't auto-complete if there are pending approvals — the agent is
// blocked waiting for the operator, not done. Auto-completing here
// would close the session and the operator's approval would land on a
// dead task. Confirmed in eval: agent hits P5 approval gate, turn
// ends, auto-complete fires incorrectly because the approval-queue
// `run` responses were logged as success=true in agent_activity.
if a.store.hasPendingApprovals(ctx, sessionID) {
return
}
discovery := a.store.hadDiscovery(ctx, sessionID)
writeback := a.store.hadEntityWriteback(ctx, sessionID)
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
shouldComplete := a.store.allPlanStepsTerminal(ctx, sessionID)
if !shouldComplete && discovery {
shouldComplete = true
}
if !shouldComplete {
return
}
outcome := "success"
if discovery && !writeback {
outcome = "partial" // honest: work done, knowledge graph not updated
}
summary := strings.TrimSpace(responseText)
summary = strings.SplitN(summary, "\n", 2)[0]
const maxLen = 120
if len(summary) > maxLen {
summary = summary[:maxLen] + "…"
}
if summary == "" {
summary = "All plan steps completed."
}
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
} else {
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)
}
}

View File

@@ -1,17 +1,14 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
@@ -21,50 +18,9 @@ import (
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/scheduler" "github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/secrets"
"github.com/dtoro/oikos/web"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// uiHandler serves the control-room SPA from assets embedded at build time
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
// the /ui prefix is stripped to index into the embedded dist/ tree. Files are
// written via http.ServeContent (not http.FileServer) to avoid its
// index.html -> "./" canonical redirect, which loops for /ui/.
func uiHandler() http.Handler {
dist, err := web.DistFS()
if err != nil {
slog.Warn("ui: embedded assets unavailable", "error", err)
return http.NotFoundHandler()
}
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
f, err := dist.Open(name)
if err != nil {
return false
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return false
}
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
return true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
if name == "" {
name = "index.html"
}
if serve(w, r, name) {
return
}
// SPA fallback: serve index.html for unknown client-side routes.
if serve(w, r, "index.html") {
return
}
http.NotFound(w, r)
})
}
var schedulerRunner = scheduler.RunnerForMain() var schedulerRunner = scheduler.RunnerForMain()
var notifierRunner = notifier.RunnerForMain() var notifierRunner = notifier.RunnerForMain()
@@ -129,7 +85,7 @@ func main() {
go notifierRunner(ctx, pool, cfg) go notifierRunner(ctx, pool, cfg)
slog.Info("all: starting api with scheduler + notifier in background") slog.Info("all: starting api with scheduler + notifier in background")
if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil { if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
slog.Error("api failed", "error", err) slog.Error("api failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -308,7 +264,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
return fmt.Errorf("migrations: %w", err) return fmt.Errorf("migrations: %w", err)
} }
err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()) err = httpapi.ListenAndServe(ctx, pool, cfg)
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
return nil return nil
} }

96
cmd/webhook/main.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"time"
"github.com/dtoro/oikos/internal/safego"
)
func main() {
port := os.Getenv("WEBHOOK_LISTEN")
if port == "" {
port = ":9797"
}
secret := os.Getenv("WEBHOOK_HMAC_SECRET")
if secret == "" {
fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set")
os.Exit(1)
}
repoDir := os.Getenv("WEBHOOK_REPO_DIR")
if repoDir == "" {
repoDir = os.Getenv("HOME") + "/Projects/oikos"
}
mux := http.NewServeMux()
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", 400)
return
}
sigHex := r.Header.Get("X-Hub-Signature-256")
if sigHex == "" {
http.Error(w, "missing signature", 401)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sigHex), []byte(expected)) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid signature", 401)
return
}
slog.Info("webhook: deploy triggered")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"deploy started"}`))
safego.Go("webhook:deploy", func() {
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
cmd.Dir = repoDir
cmd.Env = append(os.Environ(),
"REPO_DIR="+repoDir,
"PROFILE=full",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
start := time.Now()
if err := cmd.Run(); err != nil {
slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start))
return
}
slog.Info("webhook: deploy succeeded", "duration", time.Since(start))
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("ok"))
})
slog.Info("webhook: listening", "port", port)
if err := http.ListenAndServe(port, mux); err != nil {
slog.Error("webhook: serve failed", "error", err)
os.Exit(1)
}
}

View File

@@ -1,35 +1,59 @@
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover # Caddy reverse-proxy snippet for Oikos — Phase 6 cutover, updated for the
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). # client/server split (plans/2026-07-12-wails-desktop-app.md, Phase 0).
# Replaces the old MCP server on apps/105 with the Docker stack on mac-mini. # Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). THIS COPY
# IS A REFERENCE, NOT DEPLOYED FROM HERE — keep it in sync manually.
#
# The SPA is no longer embedded in the oikos binary; it's built and served
# by its own container (compose/web/Dockerfile, docker-compose.yml's `web`
# service, mac-mini:8091) rather than as static files read off local disk —
# see that service's comment for why. Every API/MCP/agent route now requires
# a bearer token in all cases (api's dev-open bypass was removed) —
# non-browser clients (Wails, curl, a future mobile client) can't complete
# Authentik's browser-session login, so those routes bypass `import
# authentik` the same way the enrollment endpoint always has and rely on
# api's own combinedAuth instead. See the Wails plan's "Plan review"
# section, gap 1.
#
# mac-mini and the LXC subnet are routed, so these target its direct LAN IP
# rather than the mesh (netbird) hostname.
# Oikos REST API (operator) — enrollment endpoint bypasses Authentik
oikos.hubris.network { oikos.hubris.network {
tls { tls {
dns ionos {env.IONOS_AUTH_API_TOKEN} dns ionos {env.IONOS_AUTH_API_TOKEN}
} }
@enroll path /api/v1/clients/enroll @enroll path /api/v1/clients/enroll /oidc-callback
handle @enroll { handle @enroll {
reverse_proxy <mac-mini-mesh-ip>:8090 reverse_proxy 192.168.178.182:8090
} }
# Nomos agent, same-origin for the control-room UI (EventSource/fetch can't # Bearer-token clients — api's combinedAuth (internal/httpapi/server.go)
# set cross-origin auth headers). Authentik gates it; handle_path strips # is the real gate for all three; Authentik would just reject non-browser
# the /agent prefix so /agent/chat -> nomos /chat. # callers before they ever get there. /agent/* now goes through api's own
handle_path /agent/* { # (auth'd) proxy mount rather than straight to nomos:8092, so it's
import authentik # covered by the same check as /api/v1/* and /mcp.
reverse_proxy <mac-mini-mesh-ip>:8092 @api path /api/v1/* /mcp /agent/*
handle @api {
reverse_proxy 192.168.178.182:8090
} }
# Everything else: the static SPA shell, served by the `web` container.
# No sensitive data lives here — real enforcement is the bearer-token
# check above — Authentik is just a first line of defense against
# anonymous crawlers finding the bundle.
handle { handle {
import authentik import authentik
reverse_proxy <mac-mini-mesh-ip>:8090 reverse_proxy 192.168.178.182:8091
} }
} }
# Oikos MCP endpoint (agents) — no auth required # Oikos MCP endpoint (agents) — bearer token required (api's combinedAuth),
# no separate gate here.
mcp.hubris.network { mcp.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8090 reverse_proxy 192.168.178.182:8090
} }
# Nomos gateway (workstation access) — formerly hermes.hubris.network # Nomos's own gateway (workstation access) — still has NO auth of its own
# (C1, plans/2026-07-11-nomos-agent-code-review.md, still open). Anyone who
# can reach this host can talk to nomos directly, bypassing api entirely.
# Not fixed by the client/server split — tracked separately.
nomos.hubris.network { nomos.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8092 reverse_proxy 192.168.178.182:8092
} }

View File

@@ -1,14 +1,7 @@
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary) # Dockerfile for Oikos API server. The SPA is no longer embedded (see
# Stage 1: build web UI # plans/2026-07-12-wails-desktop-app.md 0.1) — it's built and deployed
FROM node:22-alpine AS ui-builder # separately as static files (see `make ui` / `make deploy-ui`).
# Stage 1: build Go binary
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: build Go binary
FROM golang:1.26-alpine AS builder FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
@@ -18,8 +11,6 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets.
COPY --from=ui-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
@@ -31,6 +22,5 @@ RUN apk add --no-cache ca-certificates openssh-client-default
COPY --from=builder /oikos /oikos COPY --from=builder /oikos /oikos
COPY --from=builder /build/seeds /seeds COPY --from=builder /build/seeds /seeds
COPY --from=builder /build/migrations /migrations COPY --from=builder /build/migrations /migrations
# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed.
ENTRYPOINT ["/oikos"] ENTRYPOINT ["/oikos"]

5
compose/web/Caddyfile Normal file
View File

@@ -0,0 +1,5 @@
:80 {
root * /srv
file_server
try_files {path} /index.html
}

19
compose/web/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Dockerfile for the oikos control-room SPA. Built separately from the
# oikos binary (compose/oikos/Dockerfile) — see docker-compose.yml's `web`
# service. The outer production Caddy (caddy-conf repo, LXC 121) handles
# Authentik + splits /api/*, /mcp, /agent/* off to the api service; this
# container only serves static files with SPA-fallback routing.
FROM node:22-alpine AS builder
WORKDIR /build/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY VERSION ./
COPY web/ ./
RUN npm run build
FROM caddy:2-alpine
COPY --from=builder /build/web/dist /srv
COPY compose/web/Caddyfile /etc/caddy/Caddyfile

View File

@@ -1,10 +1,17 @@
# Docker Compose for Oikos development # Docker Compose for Oikos development
# Usage: docker compose up -d postgres (just the DB) # Usage: docker compose up -d postgres (just the DB)
# make dev (full dev stack) # make dev (full dev stack)
#
# The SPA isn't embedded in the oikos binary (see
# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this
# stack as its own `web` service (compose/web/Dockerfile), so it deploys
# through the same push-to-main pipeline as everything else. `npm run dev`
# in web/ is still the fast local-iteration path.
services: services:
postgres: postgres:
image: timescale/timescaledb:2.17.2-pg16 image: timescale/timescaledb:2.17.2-pg16
restart: unless-stopped
environment: environment:
POSTGRES_DB: oikos POSTGRES_DB: oikos
POSTGRES_USER: oikos POSTGRES_USER: oikos
@@ -51,6 +58,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -60,6 +68,12 @@ services:
OIKOS_API_LISTEN: ":8090" OIKOS_API_LISTEN: ":8090"
OIKOS_ENV: dev OIKOS_ENV: dev
OIKOS_DEBUG: "true" OIKOS_DEBUG: "true"
# No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) —
# every request needs this token. nomos uses the same value to call
# back into api's /mcp and /api/v1/approvals/*/decision.
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
NOMOS_PROXY_URL: http://nomos:8092 NOMOS_PROXY_URL: http://nomos:8092
volumes: volumes:
@@ -75,6 +89,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -98,6 +113,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -119,6 +135,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/nomos/Dockerfile dockerfile: compose/nomos/Dockerfile
restart: unless-stopped
profiles: ["full"] profiles: ["full"]
depends_on: depends_on:
api: api:
@@ -129,14 +146,32 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro} NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
# rejects every request without it now (no dev-open bypass).
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
ports: ports:
- "8092:8092" - "8092:8092"
stop_signal: SIGTERM stop_signal: SIGTERM
stop_grace_period: 10s stop_grace_period: 10s
# Control-room SPA — static build served behind Caddy. The outer
# production Caddy (caddy-conf repo, LXC 121) splits /api/*, /mcp,
# /agent/* off to api:8090 and sends everything else here; this
# container only serves static files with SPA-fallback routing.
web:
build:
context: .
dockerfile: compose/web/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"]
ports:
- "8091:80"
stop_signal: SIGTERM
# Redis (required by Infisical — Phase 5) # Redis (required by Infisical — Phase 5)
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped
profiles: ["infisical", "full"] profiles: ["infisical", "full"]
volumes: volumes:
- redis-data:/data - redis-data:/data
@@ -149,6 +184,7 @@ services:
# Infisical self-hosted (Phase 5 secrets management) # Infisical self-hosted (Phase 5 secrets management)
infisical: infisical:
image: infisical/infisical:latest image: infisical/infisical:latest
restart: unless-stopped
profiles: ["infisical", "full"] profiles: ["infisical", "full"]
depends_on: depends_on:
postgres: postgres:

View File

@@ -1,4 +1,7 @@
# Signal Trigger Architecture # ADR 0013 — Signal trigger architecture
**Status:** Accepted
**Date:** 2026-07-08
## Overview ## Overview

View File

@@ -1,6 +1,6 @@
# Oikos Entity Model — Types, Relationships & Interactions # ADR 0014 — Entity model: types, relationships & interactions
**Status:** Adopted **Status:** Accepted
**Date:** 2026-07-08 **Date:** 2026-07-08
**Scope:** Full inventory of every entity type, relationship, state machine, and **Scope:** Full inventory of every entity type, relationship, state machine, and
cognition pipeline — with clear markers for what is **code-real** vs **schema-only**. cognition pipeline — with clear markers for what is **code-real** vs **schema-only**.

View File

@@ -0,0 +1,62 @@
# ADR 0015 — Bearer-token auth for every route + client/server split
Status: accepted (2026-07-12) · Plan: plans/2026-07-12-wails-desktop-app.md, Phase 0
## Context
The control-room SPA was embedded in the `oikos` binary (`go:embed`,
ADR 0001) and served at `/ui/*`. `combinedAuth` (`internal/httpapi/server.go`)
opened a dev-open bypass — no credential required at all — whenever
`OIKOS_ENV=dev` and no static token/OIDC issuer was configured. That was
true not just in local dev but in the actual mac-mini production deploy:
`docker-compose.yml`'s `api` service hardcoded `OIKOS_ENV: dev` with no
token set, so every route (`/api/v1/*`, `/mcp`, and an `/agent` reverse-proxy
mount to nomos that had never been wrapped in `combinedAuth` at all) was
reachable unauthenticated from anywhere on the mesh/LAN. A planned Wails
desktop client and any future non-browser client can't rely on same-origin
requests or a dev-open bypass; they need the SPA to be a standalone,
CORS-capable client that authenticates over HTTP like any other caller.
## Decision
- Delete the SPA embed (`web/embed.go`, the `/ui/*` routes). `web/` is a
standalone static build, deployed separately (`make ui` / `make
deploy-ui`), served at `/` by Caddy with SPA fallback.
- Remove the dev-open bypass entirely. Every route requires a valid
static bearer token (`OIKOS_API_TOKEN` / `OIKOS_MCP_BEARER_TOKEN`) or an
OIDC JWT, with two narrow exceptions: `/healthz` (liveness) and
`POST /api/v1/clients/enroll` (IP-gated in the handler instead).
`GET /api/v1/events/stream` additionally accepts the token as a
`?token=` query param, since `EventSource` can't set custom headers.
- Add CORS (`github.com/go-chi/cors`, `OIKOS_CORS_ORIGIN`, default `*`) so a
cross-origin SPA (Vite dev server, a future Wails webview) can reach the
API. No `AllowCredentials` — auth is a header, not a cookie, so
credentialed CORS mode isn't needed and the two don't combine safely with
a wildcard origin.
- Wrap the previously-unauthenticated `/agent` proxy mount in the same
`combinedAuth` middleware as every other route.
- `cmd/nomos` becomes an authenticated client of `api`: it now sends
`Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN` on its own outbound calls
(MCP + the chat-assent approval-decision endpoint), which it never did
before — dev-open covered for it until now.
- The SPA gets a runtime config module (`web/src/lib/config.ts`) and a
first-launch `Config.svelte` screen: server URL + token, stored in
`localStorage`, injected into every `fetch()` via a shared
`fetchWithAuth` wrapper. Resolved fresh per request (not cached at
module-load time), so the same build works same-origin or cross-origin
without a rebuild.
## Consequences
- Closing dev-open was a live security fix, not just future-proofing —
verified post-deploy that unauthenticated requests to production now 401.
- Nomos's *own* HTTP gateway (`cmd/nomos`, port 8092) still has no auth of
its own — out of scope here, tracked separately
(plans/2026-07-11-nomos-agent-code-review.md, finding C1).
- Production Caddy (`dtoro/caddy-conf`, not this repo) does not yet expose
`oikos.hubris.network` at all, so the interaction between Authentik
forward-auth and bearer-token clients (a non-browser client can't
complete a browser SSO redirect) is unresolved — needs an `@enroll`-style
bypass for `/api/v1/*`/`/mcp`/`/agent/*` before public exposure. This
repo's `compose/caddy/Caddyfile.oikos` (a reference copy, not deployed
from here) has the bypass; the real config does not yet.
- There is one shared bearer secret for all agents/clients, not per-client
tokens — acceptable for the current fleet size, revisit if per-client
revocation becomes necessary.

View File

@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
Statuses: proposed | accepted | superseded-by-NNNN. Statuses: proposed | accepted | superseded-by-NNNN.
| ADR | Title | | ADR | Title |
|---|---|---| |---|---|
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging | | [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore | | [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests | | [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
@@ -16,7 +16,8 @@ Statuses: proposed | accepted | superseded-by-NNNN.
| [0008](0008-forward-only-migrations.md) | Forward-only migrations | | [0008](0008-forward-only-migrations.md) | Forward-only migrations |
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream | | [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback | | [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync | | [0011](0011-client-lifecycle-flows.md) | Client lifecycle sequence diagrams |
| [0012](0012-hermes-oikos-interactions.md) | HermesOikos interactions — agent/OS contract | | [0012](0012-hermes-oikos-interactions.md) | Hermes/Oikos interaction architecture |
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler | | [0013](0013-signal-triggers.md) | Signal trigger architecture |
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop | | [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |
| [0015](0015-api-bearer-auth-client-server-split.md) | Bearer-token auth for every route + client/server split |

55
evals/golden.yaml Normal file
View File

@@ -0,0 +1,55 @@
# Golden conversation evals for the nomos agent.
# Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
#
# Each conversation costs real OpenRouter credits (~$0.010.05). The runner
# sends the prompt, waits for the turn to finish, optionally sends a followup,
# and scores assertions against the final persisted transcript.
#
# These are STRUCTURAL assertions only — tool-call sequences, plan steps,
# writeback, completion. Text quality is model-dependent and not scored.
# --- eval 1: trivial read-only task (degenerate case) ---
- name: trivial_readonly
prompt: "What is the state of lxc:dns? One line, no plan needed."
assertions:
- kind: completes
- kind: no_propose_plan # trivial — no ceremony
- kind: max_tool_calls
value: 5 # get_entity + complete_task + maybe one more
- kind: no_run # read-only, no `run` needed
# --- eval 2: the operator's original bug — plan + proceed ---
- name: plan_advances_on_proceed
prompt: "Check the uptime of lxc:gitea. Plan it out, propose the plan, then wait for my approval before running anything."
followup: "proceed with the rest"
assertions:
- kind: completes
- kind: proposes_plan_once # propose_plan called exactly once
- kind: no_duplicate_proposal # the original bug: re-propose on "proceed"
- kind: writes_back # ran `run` → must update_entity_attributes (D.1)
- kind: no_duplicate_complete # C.1 — complete_task called at most once
# --- eval 3: UI complaint should not re-execute (C.2) ---
- name: ui_complaint_no_rerun
prompt: "Check the uptime of lxc:dns. Plan it out and wait for my approval."
followup: "go ahead"
assertions:
- kind: completes
- kind: proposes_plan_once
- kind: writes_back
# (The followup "go ahead" is approval, not a UI complaint — we'd test the
# complaint path separately with a second followup, but that needs the
# session to stay open after completion, which the runner doesn't support yet.
# For now this validates the approval-vocabulary path.)
# --- eval 4: knowledge preferred over fleet re-execution (E.1) ---
# A same-day fleet audit knowledge entry exists in the DB. The agent should
# search_knowledge first and NOT run `run` against 20 LXCs.
- name: knowledge_preferred_over_rerun
prompt: "Give me an overview of what needs updating across the homelab, categorize by criticality. There may be a recent audit already."
assertions:
- kind: completes
- kind: calls_tool
value: search_knowledge # E.1 — must check the knowledge base first
- kind: max_run_calls
value: 4 # NOT 20+ — a targeted refresh only

View File

@@ -0,0 +1,18 @@
# P5 eval: iteration. A read-only task completes; the follow-up asks the
# agent to act on what it found (a config_mutation). The second followup
# is the approval ("go ahead") so the plan can execute. Asserts the session
# reopens, a second plan generation is created, and the agent completes
# both sub-tasks.
- name: iteration-followup
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
followups:
- "The repos folder failed last time. Reset the failed service and re-run the backup."
- "go ahead"
assertions:
- kind: completes
- kind: plan_generations
value: 2
- kind: proposes_plan
- kind: writes_back
- kind: max_run_calls
value: 40

View File

@@ -0,0 +1,17 @@
# P5 eval: two read-only sub-tasks back-to-back. The follow-up is also
# read-only (no approval needed) so the agent can execute immediately after
# proposing the second plan. Asserts the session reopens and a second plan
# generation is created.
- name: iteration-readonly
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled?"
followups:
- "Now check the uptime of lxc:dns."
assertions:
- kind: completes
- kind: plan_generations
value: 2
- kind: proposes_plan
- kind: calls_tool
value: run
- kind: max_run_calls
value: 6

10
evals/no-plan-no-run.yaml Normal file
View File

@@ -0,0 +1,10 @@
# P5 eval: a pure-DB Q&A that calls NO run. This is the ONLY remaining
# carve-out from plan-first: a task that never touches a live target via
# `run` doesn't need propose_plan (the gate only fires on run). Asserts
# the agent answers directly and completes without ceremony.
- name: no-plan-no-run
prompt: "List all LXC containers and their current health."
assertions:
- kind: completes
- kind: no_run
- kind: no_propose_plan

View File

@@ -0,0 +1,14 @@
# P5 eval: a read-only question that requires live inspection (not just DB
# lookup). Asserts the plan-first gate works: the agent must propose_plan
# before run, even for a trivial read-only task.
- name: plan-always-readonly
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
assertions:
- kind: completes
- kind: proposes_plan
- kind: plan_before_run
- kind: calls_tool
value: run
- kind: writes_back
- kind: max_run_calls
value: 6

11
go.mod
View File

@@ -5,6 +5,7 @@ go 1.26.3
require ( require (
github.com/getkin/kin-openapi v0.140.0 github.com/getkin/kin-openapi v0.140.0
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3 github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -13,6 +14,8 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/oapi-codegen/runtime v1.4.2 github.com/oapi-codegen/runtime v1.4.2
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
@@ -24,6 +27,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.1.11 // indirect cloud.google.com/go/iam v1.1.11 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
@@ -39,12 +43,16 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
github.com/aws/smithy-go v1.20.2 // indirect github.com/aws/smithy-go v1.20.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/flock v0.8.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
@@ -53,6 +61,9 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oasdiff/yaml v0.1.0 // indirect github.com/oasdiff/yaml v0.1.0 // indirect
github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/oasdiff/yaml3 v0.0.13 // indirect
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect

35
go.sum
View File

@@ -7,6 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw=
cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
@@ -40,12 +42,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
@@ -57,11 +63,17 @@ github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
@@ -71,6 +83,8 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -101,11 +115,19 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
@@ -142,6 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -159,12 +183,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -214,13 +242,16 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -23,6 +23,12 @@ type Config struct {
MCPBearerToken string // shared secret for Nomos→API MCP calls MCPBearerToken string // shared secret for Nomos→API MCP calls
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
OIDCClientID string // OIDC client ID (aud claim expected in JWT) OIDCClientID string // OIDC client ID (aud claim expected in JWT)
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
// 0.3). Needed for the Wails webview and local dev (Vite on a different
// port than the API); a no-op when the SPA and API share an origin.
CORSAllowedOrigin string
// Observability // Observability
Debug bool // verbose logging, probe payloads, SQL Debug bool // verbose logging, probe payloads, SQL
@@ -73,6 +79,7 @@ func Default() Config {
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable", DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
APIListen: ":8090", APIListen: ":8090",
APIEnv: "dev", APIEnv: "dev",
CORSAllowedOrigin: "*",
SeedsDir: "seeds", SeedsDir: "seeds",
MigrationsDir: "migrations", MigrationsDir: "migrations",
SchedulerInterval: 30 * time.Second, SchedulerInterval: 30 * time.Second,
@@ -102,12 +109,18 @@ func FromEnv() Config {
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" { if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
c.OIDCClientID = v c.OIDCClientID = v
} }
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
c.OIDCClientSecret = v
}
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" { if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
c.APIToken = v c.APIToken = v
} }
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" { if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
c.MCPBearerToken = v c.MCPBearerToken = v
} }
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
c.CORSAllowedOrigin = v
}
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" { if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
c.SeedsDir = v c.SeedsDir = v
} }

View File

@@ -93,15 +93,34 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
} }
} }
return NewHandler(handlerCtx, pool, cfg, nil) return NewHandler(handlerCtx, pool, cfg)
}
// testAuthToken is the static bearer token devConfig() configures. There is
// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4),
// so every test handler needs a real credential; get/postJSON/do inject it
// by default. Pass an explicit "" value for "Authorization" in headers to
// test the no-credential path.
const testAuthToken = "test-dev-token"
// applyHeaders sets req's default Authorization header, then layers headers
// on top. A "" value deletes the header instead of setting it, so tests can
// exercise the missing-credential case.
func applyHeaders(req *http.Request, headers map[string]string) {
req.Header.Set("Authorization", "Bearer "+testAuthToken)
for k, v := range headers {
if v == "" {
req.Header.Del(k)
} else {
req.Header.Set(k, v)
}
}
} }
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) { func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper() t.Helper()
req := httptest.NewRequest("GET", path, nil) req := httptest.NewRequest("GET", path, nil)
for k, v := range headers { applyHeaders(req, headers)
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var body map[string]any var body map[string]any
@@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
t.Helper() t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(payload)) req := httptest.NewRequest("POST", path, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
applyHeaders(req, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var body map[string]any var body map[string]any
@@ -122,7 +142,8 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
func devConfig() config.Config { func devConfig() config.Config {
c := config.Default() c := config.Default()
c.APIEnv = "dev" // no tokens → dev-open auth c.APIEnv = "dev"
c.APIToken = testAuthToken
return c return c
} }
@@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) {
} }
// API requires the token // API requires the token
rec, body := get(t, h, "/api/v1/entities", nil) rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""})
if rec.Code != 401 { if rec.Code != 401 {
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body) t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
} }

View File

@@ -304,10 +304,24 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY e.slug`, rootID, depth, req.Params.RelType) ORDER BY e.slug`, rootID, depth, req.Params.RelType)
} else { } else {
// Whole-graph view: pick the most-connected entities first so the
// graph shows actual topology, not just whatever sorts first
// alphabetically. Without this the cap fills with exec:* rows and
// drops every host/lxc/service/vm — and every edge those entities
// connect — because edges require both endpoints in the node set.
nodes, err = s.queryEntities(ctx, ` nodes, err = s.queryEntities(ctx, `
SELECT `+entityCols+` FROM entities e SELECT `+entityCols+`
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY e.slug LIMIT $1`, WHERE e.id IN (
SELECT e2.id FROM entities e2
LEFT JOIN relationships r ON r.valid_to IS NULL
AND (r.source_id = e2.id OR r.target_id = e2.id)
GROUP BY e2.id
ORDER BY count(r.type) DESC, e2.slug
LIMIT $1
)
ORDER BY e.slug`,
graphNodeCap+1) graphNodeCap+1)
if err == nil && len(nodes) > graphNodeCap { if err == nil && len(nodes) > graphNodeCap {
nodes = nodes[:graphNodeCap] nodes = nodes[:graphNodeCap]
@@ -1290,7 +1304,10 @@ func (s *Server) GetClientContext(ctx context.Context, req gen.GetClientContextR
for rows.Next() { for rows.Next() {
var p string var p string
if scanErr := rows.Scan(&p); scanErr == nil { if scanErr := rows.Scan(&p); scanErr == nil {
if strings.HasPrefix(p, "tools/") && strings.HasSuffix(p, ".setup.sh") { // Matches tools/setup-*.sh (the auto-setup convention —
// see tools/post-pull.sh). Was tools/*.setup.sh until
// 2026-07-12, which never matched any real filename.
if strings.HasPrefix(p, "tools/setup-") && strings.HasSuffix(p, ".sh") {
toolsChanged = append(toolsChanged, p) toolsChanged = append(toolsChanged, p)
} else if p == ".sops.yaml" { } else if p == ".sops.yaml" {
sopsChanged = true sopsChanged = true

View File

@@ -5,9 +5,12 @@ import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
) )
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has // serveRecentKnowledge backs the Knowledge page's "what the system knows / has
@@ -106,12 +109,58 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
}) })
} }
// serveKnowledgeContent returns the full markdown body for a document/
// investigation/runbook entity, by its own entity id or slug. Nothing else
// exposes knowledge_entities.content — GetEntityKnowledge (below) answers a
// different question ("what knowledge references THIS entity"), and
// SearchKnowledge only returns a short ts_headline snippet. The KB detail
// panel needs the entity's own full content when it IS a knowledge entity.
func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
// chi.URLParam returns the raw, still-percent-encoded segment (unlike
// the OpenAPI-generated routes, which decode via
// runtime.BindStyledParameterWithOptions before reaching the handler) —
// slugs like "document:containers/101-jellyfin" arrive as
// "document%3Acontainers%2F101-jellyfin" and must be unescaped here.
idOrSlug, err := url.PathUnescape(chi.URLParam(req, "id"))
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
var title, content, source string
var tags []string
var updatedAt string
err = s.pool.QueryRow(ctx, `
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
Scan(&title, &content, &source, &tags, &updatedAt)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
return
}
if tags == nil {
tags = []string{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"title": title,
"content": content,
"source": source,
"tags": tags,
"updated_at": updatedAt,
})
}
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) { func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
q := request.Params.Q q := request.Params.Q
limit := clampLimit(request.Params.Limit) limit := clampLimit(request.Params.Limit)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags, SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1), ts_headline('english', ke.content, plainto_tsquery('english', $1),
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
@@ -131,12 +180,13 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
items := []gen.KnowledgeHit{} items := []gen.KnowledgeHit{}
for rows.Next() { for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string var slug, eType, title, source string
var tags []string var tags []string
var rank float32 var rank float32
var snippet *string var snippet *string
if err := rows.Scan(&slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil { if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
return nil, err return nil, err
} }
@@ -149,6 +199,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
} }
items = append(items, gen.KnowledgeHit{ items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug, Slug: slug,
Title: title, Title: title,
Type: hitType, Type: hitType,
@@ -172,7 +223,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
entitySlug := request.EntityId entitySlug := request.EntityId
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type JOIN entity_types et ON et.name = e.type
@@ -182,7 +233,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
AND r.valid_to IS NULL AND r.valid_to IS NULL
AND r.type IN ('documents', 'about') AND r.type IN ('documents', 'about')
UNION UNION
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type JOIN entity_types et ON et.name = e.type
@@ -191,7 +242,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL WHERE r.valid_to IS NULL
AND r.type = 'procedure-for' AND r.type = 'procedure-for'
ORDER BY 1`, ORDER BY 2`,
entitySlug) entitySlug)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -200,10 +251,11 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
items := []gen.KnowledgeHit{} items := []gen.KnowledgeHit{}
for rows.Next() { for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string var slug, eType, title, source string
var tags []string var tags []string
if err := rows.Scan(&slug, &eType, &title, &source, &tags); err != nil { if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags); err != nil {
return nil, err return nil, err
} }
@@ -216,6 +268,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
} }
items = append(items, gen.KnowledgeHit{ items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug, Slug: slug,
Title: title, Title: title,
Type: hitType, Type: hitType,

View File

@@ -24,9 +24,7 @@ func do(t *testing.T, h http.Handler, method, path string, body any, headers map
} }
req := httptest.NewRequest(method, path, rdr) req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
for k, v := range headers { applyHeaders(req, headers)
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var decoded map[string]any var decoded map[string]any

View File

@@ -18,6 +18,7 @@ import (
"github.com/dtoro/oikos/internal/domain" "github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -117,6 +118,13 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
} }
done := make(chan result, 1) done := make(chan result, 1)
go func() { go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command) out, err := session.CombinedOutput(command)
done <- result{out, err} done <- result{out, err}
}() }()
@@ -231,6 +239,32 @@ func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, st
severity = "warning" severity = "warning"
} }
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail) _ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
if status == "completed" || status == "failed" || status == "cancelled" {
closePlanStepForExecution(ctx, pool, execID, status)
}
}
// closePlanStepForExecution auto-closes a task plan step whose linked execution
// just reached a terminal state, so the task board advances even if the agent
// doesn't call update_plan_step itself (belt and suspenders — the agent links
// the step to the execution when it starts it; the api finishes it here). Emits
// plan.step.finished correlated to the step's session. No-op for the vast
// majority of executions, which aren't plan steps.
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
stepStatus := "done"
if execStatus == "failed" || execStatus == "cancelled" {
stepStatus = "failed"
}
var stepID, sessionID string
var seq int
if err := pool.QueryRow(ctx, `
UPDATE session_plan_steps SET status = $2, finished_at = now()
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
return // no matching open step
}
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
} }
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) { func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
@@ -1427,7 +1461,9 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
// Resolve target entity slug from targetID. // Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr) safego.Go("httpapi:executeApprovedAction", func() {
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
})
// Status only — risk_class was set correctly at request time // Status only — risk_class was set correctly at request time
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to // (e.g. by policy.ClassifyCommand for `run`); overwriting it to
// a hardcoded 'config_mutation' here corrupted the audit ledger // a hardcoded 'config_mutation' here corrupted the audit ledger

View File

@@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) {
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body)) req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+testAuthToken)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)

View File

@@ -12,6 +12,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math/big" "math/big"
"net/http" "net/http"
@@ -26,8 +27,10 @@ import (
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp" mcphandler "github.com/dtoro/oikos/internal/mcp"
"github.com/dtoro/oikos/internal/safego"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -69,7 +72,7 @@ type secretsBackend interface {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases // before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks. // and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler { func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{ s := &Server{
pool: pool, pool: pool,
cfg: cfg, cfg: cfg,
@@ -78,12 +81,21 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
} }
// Start background SSE listener, tied to ctx for clean shutdown. // Start background SSE listener, tied to ctx for clean shutdown.
go s.sseListener(ctx) // handleNotification (called per-message inside sseListener's loop) has
// its own recover for the common case; this outer one covers the
// connection-setup/reconnect code around it.
safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(requestLogger) r.Use(requestLogger)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
MaxAge: 86400,
}))
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy. // Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
@@ -116,6 +128,23 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
} }
}) })
// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
// to build the authorization URL, and uses the token proxy to exchange
// authorization codes and refresh tokens without CORS issues.
r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCConfig(w, req, cfg)
})
r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCToken(w, req, cfg)
})
// Desktop OIDC callback — standalone HTML page that exchanges the
// authorization code for tokens and displays the access token to copy
// into the desktop app's Config screen.
r.Get("/oidc-callback", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCCallback(w, req, cfg)
})
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{ strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
@@ -126,7 +155,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
gen.HandlerWithOptions(strict, gen.ChiServerOptions{ gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1", BaseURL: "/api/v1",
BaseRouter: r, BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)}, Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
}, },
@@ -137,24 +166,32 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
// registration wins). The strict-server path can't Flush() per event; // registration wins). The strict-server path can't Flush() per event;
// this one uses the real ResponseWriter for real-time delivery. It // this one uses the real ResponseWriter for real-time delivery. It
// inherits the router's base middleware and applies auth via With(). // inherits the router's base middleware and applies auth via With().
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE) // allowQueryToken=true: EventSource can't set custom headers, so the
// SPA passes the token as ?token=... instead of Authorization.
r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE)
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the // Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
// Knowledge page's "what the system has learned" view. Registered after // Knowledge page's "what the system has learned" view. Registered after
// HandlerWithOptions so it wins over any generated catch-all. // HandlerWithOptions so it wins over any generated catch-all.
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
// Custom (non-OpenAPI) route: full markdown content for a knowledge
// entity (document/investigation/runbook) by its own id or slug — the
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
// different question (knowledge referencing this entity), not this one.
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered, // Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the // unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest. // per-session "what did this session do" digest.
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity) r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
// Learning view: capability timeline + success trend, both derived from // Learning view: capability timeline + success trend, both derived from
// executions (real, growing data) rather than the patterns/skills tables, // executions (real, growing data) rather than the patterns/skills tables,
// which are correctly modeled but have no writers anywhere yet. // which are correctly modeled but have no writers anywhere yet.
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline) r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend) r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
// Mount MCP at /mcp (plan R3-10) // Mount MCP at /mcp (plan R3-10)
nomosAgentID := uuid.Nil nomosAgentID := uuid.Nil
@@ -166,33 +203,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" { if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID) _ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
} }
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID)) r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
if uiHandler != nil {
uiHandler.ServeHTTP(w, req)
}
})
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL) target, _ := url.Parse(nomosURL)
proxy := httputil.NewSingleHostReverseProxy(target) proxy := httputil.NewSingleHostReverseProxy(target)
r.Mount("/agent", http.StripPrefix("/agent", proxy)) // Was unauthenticated (pre-existing gap, predates the client/server
// split — this mount was never wrapped in combinedAuth, unlike every
// other custom route below). Harmless while dev-open was in effect;
// a real hole now that every route needs a real credential.
r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
} }
return r return r
} }
// combinedAuth tries OIDC JWT validation first (if configured), falls back to // combinedAuth tries OIDC JWT validation first (if configured), then falls
// static bearer token validation, and opens the gate in dev mode when no // back to static bearer token validation. Every request needs a valid
// credentials are configured. // credential — there is no dev-open bypass (closed as part of the
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { // client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the
// SPA is a separate client, a dev-open API is reachable from any origin).
// When allowQueryToken is set, a missing Authorization header falls back to
// a `?token=` query param — only used for the SSE route, since EventSource
// can't set custom headers.
func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler {
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != "" hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
@@ -213,31 +247,14 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
} }
} }
var staticTokens [][]byte
if cfg.APIToken != "" {
staticTokens = append(staticTokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: "system",
Label: "dev:anonymous",
ID: "dev",
TokenType: "none",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ") raw, ok := strings.CutPrefix(auth, "Bearer ")
if (!ok || raw == "") && allowQueryToken {
raw = r.URL.Query().Get("token")
ok = raw != ""
}
if !ok || raw == "" { if !ok || raw == "" {
writeProblem(w, r, http.StatusUnauthorized, "unauthorized", writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing bearer token") "missing bearer token")
@@ -271,23 +288,12 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// Fall back to static tokens // Fall back to static tokens
if hasStatic { if hasStatic {
for _, t := range staticTokens { if act, ok := staticTokenActor(cfg, raw); ok {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 { ctx := context.WithValue(r.Context(), actorKey, act)
label := "operator:api"
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
label = "agent:mcp"
}
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: label[:strings.IndexByte(label, ':')],
Label: label,
ID: raw[:8] + "...",
TokenType: "static",
})
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
} }
} }
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized", writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"invalid or expired bearer token") "invalid or expired bearer token")
@@ -295,6 +301,28 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
} }
} }
// staticTokenActor validates raw against the configured static bearer
// tokens (API token, MCP token) in constant time and returns the resolved
// actor. Shared between combinedAuth's header-based check and serveSSE's
// query-param check (EventSource can't set custom headers, so the SSE
// stream takes the token as ?token=...).
func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
if raw == "" {
return actor{}, false
}
idPrefix := raw
if len(idPrefix) > 8 {
idPrefix = idPrefix[:8]
}
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true
}
if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 {
return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true
}
return actor{}, false
}
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT // jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid). // verification, identified by its key ID (kid).
type jwtVerificationKey struct { type jwtVerificationKey struct {
@@ -520,17 +548,285 @@ func requestLogger(next http.Handler) http.Handler {
}) })
} }
// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
// up one path segment. Authentik's issuer is per-provider
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
// path (.../application/o/<suffix>).
func resolveOIDCEndpointURL(issuer, suffix string) string {
u, err := url.Parse(issuer)
if err != nil {
return strings.TrimRight(issuer, "/") + suffix
}
u.Path = strings.TrimRight(u.Path, "/")
if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
u.Path = u.Path[:idx]
}
u.Path += suffix
return u.String()
}
// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
func resolveOIDCTokenURL(issuer string) string {
return resolveOIDCEndpointURL(issuer, "/token/")
}
// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
// authorization URLs without hardcoding them.
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"issuer": cfg.OIDCIssuer,
"client_id": cfg.OIDCClientID,
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
})
}
// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
type tokenExchangeBody struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// serveOIDCToken proxies authorization_code and refresh_token grants to the
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
// because of CORS; this proxy avoids the cross-origin problem entirely.
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
return
}
var tb tokenExchangeBody
if err := json.Unmarshal(body, &tb); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
return
}
// Build the form-encoded body for Authentik's token endpoint
form := url.Values{}
form.Set("client_id", cfg.OIDCClientID)
if cfg.OIDCClientSecret != "" {
form.Set("client_secret", cfg.OIDCClientSecret)
}
switch tb.GrantType {
case "authorization_code":
form.Set("grant_type", "authorization_code")
form.Set("code", tb.Code)
form.Set("code_verifier", tb.CodeVerifier)
form.Set("redirect_uri", tb.RedirectURI)
case "refresh_token":
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", tb.RefreshToken)
default:
writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
return
}
tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
slog.Error("oidc token proxy failed", "error", err)
writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
return
}
if resp.StatusCode >= 400 {
slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Write(respBody)
}
// serveOIDCCallback serves a standalone HTML page that completes the
// desktop OIDC login flow. Authentik redirects here with ?code=...&state=...
// after the user authorizes. The state carries the PKCE verifier
// (base64url-encoded, joined with "."). The page exchanges the code for
// tokens via the token proxy, then displays the access token for the user
// to copy into the desktop app.
func (s *Server) serveOIDCCallback(w http.ResponseWriter, req *http.Request, cfg config.Config) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos — Connect Desktop App</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a; color: #e0e0e0;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px;
}
.card {
background: #1a1a1a; border: 1px solid #2a2a2a;
border-radius: 12px; padding: 32px; max-width: 480px; width: 100%;
}
h1 { font-size: 20px; margin-bottom: 8px; }
p { font-size: 14px; color: #888; margin-bottom: 20px; }
.spinner { margin: 24px auto; width: 32px; height: 32px; border: 3px solid #2a2a2a; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.token-box {
background: #111; border: 1px solid #2a2a2a; border-radius: 8px;
padding: 16px; font-family: monospace; font-size: 13px;
word-break: break-all; margin-bottom: 16px; position: relative;
max-height: 160px; overflow-y: auto;
}
.btn {
display: block; width: 100%; padding: 12px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; text-align: center;
}
.btn-primary { background: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn-secondary { background: #1a1a1a; color: #e0e0e0; border: 1px solid #2a2a2a; margin-top: 8px; }
.btn-secondary:hover { background: #222; }
.success { color: #22c55e; margin-bottom: 8px; font-weight: 600; }
.error { color: #ef4444; margin-bottom: 12px; }
.copied { color: #22c55e; font-size: 13px; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<div class="card">
<h1>Connect Desktop App</h1>
<div id="loading">
<p>Exchanging authorization code...</p>
<div class="spinner"></div>
</div>
<div id="result" style="display:none"></div>
</div>
<script>
async function main() {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');
if (!code || !state) {
showError('Missing code or state parameter from Authentik redirect.');
return;
}
const parts = state.split('.');
if (parts.length !== 2) {
showError('Invalid state format.');
return;
}
const [csrf, verifier] = parts;
const redirectURI = location.origin + '/oidc-callback';
try {
const resp = await fetch('/api/v1/auth/oidc-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: code,
code_verifier: verifier,
redirect_uri: redirectURI
})
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText }));
showError(err.error || err.message || 'Token exchange failed (' + resp.status + ')');
return;
}
const tokens = await resp.json();
if (!tokens.access_token) {
showError('No access token in response.');
return;
}
document.getElementById('loading').style.display = 'none';
const result = document.getElementById('result');
result.style.display = 'block';
result.innerHTML = '<div class="success">Authentication successful</div>' +
'<p style="margin-bottom:8px">Copy this token into the Oikos desktop app Token tab:</p>' +
'<div class="token-box" id="token">' + escapeHtml(tokens.access_token) + '</div>' +
'<button class="btn btn-primary" id="copyBtn">Copy Token</button>' +
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>' +
'<div class="copied" id="copied" style="display:none">Copied!</div>';
document.getElementById('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText(tokens.access_token).then(() => {
const el = document.getElementById('copied');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 2000);
});
});
} catch(e) {
showError('Network error: ' + e.message);
}
}
function showError(msg) {
document.getElementById('loading').style.display = 'none';
const result = document.getElementById('result');
result.style.display = 'block';
result.innerHTML = '<div class="error">' + escapeHtml(msg) + '</div>' +
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>';
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
main();
</script>
</body>
</html>`)
}
// ListenAndServe runs the API server with graceful shutdown on ctx cancel // ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit. // (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error { func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
srv := &http.Server{ srv := &http.Server{
Addr: cfg.APIListen, Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, uiHandler), Handler: NewHandler(ctx, pool, cfg),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
// but an unrecovered panic here would crash the whole process rather
// than surfacing as a normal startup error) and reports it through
// errCh instead — the select below would otherwise just hang waiting
// for a value that never arrives.
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
}
}()
slog.Info("api listening", "addr", cfg.APIListen) slog.Info("api listening", "addr", cfg.APIListen)
errCh <- srv.ListenAndServe() errCh <- srv.ListenAndServe()
}() }()

View File

@@ -146,10 +146,28 @@ func (s *Server) sseListener(ctx context.Context) {
continue continue
} }
s.handleNotification(ctx, nt.Payload)
}
}
// handleNotification processes one pg_notify payload: decode, fetch the full
// event, push to the broker, fan out to live subscribers. Split out of
// sseListener's loop specifically so it can be wrapped in its own recover —
// a panic while handling ONE notification (a malformed payload, an
// unexpected nil somewhere in the fan-out) must not kill the whole listener
// goroutine, which would silently stop the live event stream for every
// connected client until the api process is restarted.
func (s *Server) handleNotification(ctx context.Context, payload string) {
defer func() {
if r := recover(); r != nil {
slog.Error("sse listener: panic recovered handling notification", "panic", r)
}
}()
var p notifyPayload var p notifyPayload
if err := json.Unmarshal([]byte(nt.Payload), &p); err != nil { if err := json.Unmarshal([]byte(payload), &p); err != nil {
slog.Error("sse listener unmarshal failed", "error", err) slog.Error("sse listener unmarshal failed", "error", err)
continue return
} }
// Fetch full event from DB // Fetch full event from DB
@@ -160,7 +178,7 @@ func (s *Server) sseListener(ctx context.Context) {
}) })
if err != nil || len(events) == 0 { if err != nil || len(events) == 0 {
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err) slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
continue return
} }
ev := events[0] ev := events[0]
@@ -178,7 +196,6 @@ func (s *Server) sseListener(ctx context.Context) {
} }
} }
s.sseMu.Unlock() s.sseMu.Unlock()
}
} }
// sqlcEventToGen converts a DB event row to the canonical wire shape so the // sqlcEventToGen converts a DB event row to the canonical wire shape so the
@@ -209,12 +226,22 @@ func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
// writeSSE writes a single Event as an SSE message. Returns false if the // writeSSE writes a single Event as an SSE message. Returns false if the
// write failed (client disconnected). flusher may be nil (io.Pipe path, // write failed (client disconnected). flusher may be nil (io.Pipe path,
// which has no separate flush step). // which has no separate flush step).
//
// We deliberately DO NOT set the SSE `event:` name field, even though every
// event has a type. A named SSE event is only delivered to a matching
// addEventListener(type) handler, NOT to EventSource.onmessage — and the whole
// frontend (stores/events.ts and every page that reads liveEvents) consumes the
// stream via onmessage, reading the type from the JSON payload's `type` field.
// Emitting `event: <type>` silently routed every event away from onmessage, so
// the live stream delivered nothing to the UI. Leaving the name off sends all
// events to onmessage; the type is already in `data`, and new event types need
// zero client changes. `id:` is kept for Last-Event-ID reconnection.
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool { func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
data, err := json.Marshal(sqlcEventToGen(ev)) data, err := json.Marshal(sqlcEventToGen(ev))
if err != nil { if err != nil {
return true // skip un-serializable events return true // skip un-serializable events
} }
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data) _, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.ID, data)
if err != nil { if err != nil {
return false return false
} }

View File

@@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// Connect to the stream. // Connect to the stream.
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil) req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
req.Header.Set("Authorization", "Bearer "+testAuthToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
t.Fatalf("connect stream: %v", err) t.Fatalf("connect stream: %v", err)
@@ -60,7 +61,10 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// POSTing to the SAME live server (same DB → NOTIFY the listener sees). // POSTing to the SAME live server (same DB → NOTIFY the listener sees).
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"}) payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload)) createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+testAuthToken)
cResp, err := http.DefaultClient.Do(createReq)
if err != nil { if err != nil {
t.Fatalf("trigger create: %v", err) t.Fatalf("trigger create: %v", err)
} }

View File

@@ -90,14 +90,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
FROM entities e FROM entities e
WHERE ($1::text IS NULL OR e.type = $1) WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2) AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
ORDER BY e.slug LIMIT $4`, ORDER BY e.slug LIMIT $4`,
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
}) })
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
@@ -148,12 +148,12 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
}) })
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)", register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"query", "string", "Search terms"}), InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
q := nStr(args["query"]) q := nStr(args["query"])
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug, SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1), ts_headline('english', ke.content, plainto_tsquery('english', $1),
@@ -164,15 +164,15 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
WHERE ke.search @@ plainto_tsquery('english', $1) WHERE ke.search @@ plainto_tsquery('english', $1)
ORDER BY rank DESC ORDER BY rank DESC
LIMIT 20`, q), nil LIMIT 20`, q), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity", register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}), InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, ke.source, e.type AS kind, e.slug, SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke FROM knowledge_entities ke
@@ -192,14 +192,26 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL WHERE r.valid_to IS NULL
AND r.type = 'procedure-for' AND r.type = 'procedure-for'
ORDER BY 1`, slug), nil ORDER BY 1`, slug), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.", register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
return queryRows(ctx, pool, `
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1`, slug), nil
})
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
InputSchema: objSchema( InputSchema: objSchema(
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."}, prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."}, prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
prop{"about", "string", "Optional entity slug this knowledge concerns (e.g. lxc:typetype, host:strong) — links the note to that entity so get_entity_knowledge surfaces it."}, prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."}, prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."}, prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
), ),
@@ -208,12 +220,75 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return upsertKnowledge(ctx, pool, args) return upsertKnowledge(ctx, pool, args)
}) })
register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
attrsStr, _ := args["attributes"].(string)
if slug == "" || attrsStr == "" {
return textResult("error: slug and attributes are required"), nil
}
var attrs map[string]any
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON))
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
})
register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err := pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
})
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics", register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}), InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
hours := int(getFloat(args, "hours", 24)) hours := int(getFloat(args, "hours", 24))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket, SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric, entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg, ROUND(avg(value)::numeric, 2) AS avg,
@@ -222,7 +297,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM metric_samples FROM metric_samples
WHERE ts > now() - make_interval(hours => $1) WHERE ts > now() - make_interval(hours => $1)
GROUP BY bucket, entity_id, metric GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC LIMIT 100`, hours), nil ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
}) })
// ─── Phase 4: new tools ────────────────────────────────────────── // ─── Phase 4: new tools ──────────────────────────────────────────
@@ -282,164 +357,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
nStr(args["status"])), nil nStr(args["status"])), nil
}) })
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create. pct_create provisions a NEW LXC and installs its service in one approved step — you do NOT need follow-up pct_exec calls for package installs.", // ── request_execution (legacy fixed enum) retired 2026-07-14 ──
InputSchema: objSchema( // All mutations now route through `run`. The handler functions
prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."}, // (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"}, // for future runbook extraction — especially pct_create DNS/VMID logic.
prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24, or omit/\"dhcp\" — DHCP is the safe default, see below), gw (gateway ip, static only), bridge (e.g. vmbr0/vmbr1 — WHICH BRIDGE REACHES WHICH SUBNET IS DIFFERENT PER HOST, never assume vmbr0; see below), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"bridge\":\"vmbr1\",\"nesting\":true}. pct_create is ATOMIC — it ONLY creates and starts the container (no services/post_install params anymore). Once it completes you will be automatically re-invoked with the result; install packages and run setup by issuing your OWN `run` calls against the new lxc:<hostname> target, one step at a time — you'll see each step's real output and can fix exactly the one that fails, instead of one opaque multi-minute install that either fully works or fully doesn't. STATIC IP RULE: before setting ip/gw/bridge to anything other than DHCP, use list_entities/get_entity_knowledge to find an EXISTING lxc on the SAME host whose IP is in the same /28 block, and copy its exact gw+bridge — do not invent a gateway. If no such neighbor exists, prefer ip:\"dhcp\" (proven to work, gets a real routable address) over guessing; a wrong bridge/gateway pair fails a fast pre-flight ping check now (seconds, not minutes) but is still a wasted turn — better to not guess at all."}, // DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug, _ := args["target"].(string)
action, _ := args["action"].(string)
params, _ := args["params"].(string)
if targetSlug == "" || action == "" {
return textResult("error: target and action required"), nil
}
var targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
// restart, pct_exec, and systemctl (outside enable/disable) route
// through the same classify→gate path as `run` instead of executing
// immediately over SSH with a hardcoded risk_class='reversible_low'
// that was never actually checked against anything. Found live
// 2026-07-10: a chat request to "restart caddy" — the fleet's
// reverse proxy — executed instantly with zero approval, because
// this action bypassed the classifier entirely. classifyAndGate
// applies the same read-only/config-mutation/destructive
// classification and approval flow the `run` tool already uses.
if action == "restart" || action == "pct_exec" || (action == "systemctl" && params != "enable" && params != "disable") {
svc := strings.TrimPrefix(targetSlug, "lxc:")
var cmd, purpose string
switch action {
case "restart":
cmd = fmt.Sprintf("systemctl restart %s; sleep 1; systemctl is-active %s", svc, svc)
purpose = "restart " + svc
case "pct_exec":
cmd = params
purpose = "pct_exec (legacy) on " + targetSlug
case "systemctl":
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
purpose = "systemctl " + params + " " + svc
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, ""), nil
}
// Deduplicate: if a pending execution already exists for the same
// target+action, return the existing one instead of creating a
// duplicate. Prevents the LLM from re-requesting the same gated
// action in a tool-calling loop. Only blocks when a pending
// execution exists; completed/failed ones don't block.
if action == "systemctl" || action == "apt_upgrade" || action == "pct_create" {
execNamePrefix := action + " on " + targetSlug
var existingID string
err := pool.QueryRow(ctx, `
SELECT e.id::text FROM entities e
JOIN executions ex ON ex.entity_id = e.id
WHERE e.type = 'execution' AND e.name LIKE $1 AND ex.status = 'pending_approval'
ORDER BY e.created_at DESC LIMIT 1`, execNamePrefix+"%").Scan(&existingID)
if err == nil && existingID != "" {
return textResult(fmt.Sprintf("%s on %s is already queued for approval — execution %s. Wait for operator approval. Do not re-request.",
action, targetSlug, existingID)), nil
}
}
id, _ := uuid.NewV7()
correlationID := uuid.New().String()
// Full UUID, not a truncated prefix: UUIDv7's leading bytes encode a
// millisecond timestamp, so an 8-char prefix collides for real under
// back-to-back requests (observed live: two `run` calls seconds
// apart hit entities_slug_key). The full string is guaranteed unique.
execName := action + " on " + targetSlug + " (" + id.String() + ")"
execSlug := "exec:" + targetSlug + ":" + id.String()
_, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
id, execSlug, execName)
if err != nil {
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
}
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
id, targetID, action+":"+params, correlationID, agentID)
// Execute reversible actions immediately. restart/pct_exec/systemctl
// (outside enable/disable) never reach here — they're routed through
// classifyAndGate above, before this dedup+insert block.
switch action {
case "systemctl":
// Only enable/disable reach this case now.
svc := strings.TrimPrefix(targetSlug, "lxc:")
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
case "apt_upgrade":
if params == "audit" {
host, user, err := resolveHost(ctx, pool, targetSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err := sshExec(ctx, host, user, "apt update -qq 2>&1 >/dev/null; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l; apt list --upgradable 2>/dev/null | tail -n +2 | head -20")
if err != nil {
return textResult(fmt.Sprintf("apt audit error: %v", err)), nil
}
return textResult("apt audit:\n" + out), nil
}
// During an active assent window, auto-approve.
if assentWindowActive(ctx, pool, agentID) {
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
// Do NOT pre-flip approvals/executions status here (that was
// the previous, broken "autoApprove" helper). DecideApproval
// (invoked below) is the ONE place that transitions
// pending_approval -> approved and dispatches the real SSH
// work — it specifically looks for status='pending_approval'
// to find what to run. Pre-flipping the status past that
// state meant DecideApproval's own lookup found nothing,
// silently no-opped, and the execution sat at 'approved'
// forever with nothing actually running. Found live: every
// assent-window auto-approved pct_create/apt_upgrade has
// never actually executed, via this exact bug. Calling
// executeApprovedViaAPI directly against the untouched
// pending_approval row makes this identical to the manual
// Approve-button path, just without a human click.
//
// context.Background(), NOT ctx: ctx is scoped to this MCP
// tool call, cancelled the instant the chat turn's HTTP
// response completes (every normal turn) — a goroutine
// meant to outlive the request must not inherit its context.
go executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id)
return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil
}
// upgrade requires approval — queue
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil
case "pct_create":
// During an active assent window, auto-approve and execute
// instead of queuing — the operator already approved the plan.
if assentWindowActive(ctx, pool, agentID) {
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
// See the apt_upgrade case above for why there's no
// pre-flip-status "autoApprove" step here anymore, and why
// this uses context.Background().
go executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id)
return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil
}
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
return textResult(fmt.Sprintf("pct_create on %s requires approval — execution %s queued. The LXC will be provisioned once approved.", targetSlug, id)), nil
default:
return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade, pct_create", action)), nil
}
})
register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.", register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
InputSchema: objSchema( InputSchema: objSchema(
@@ -454,6 +376,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
command, _ := args["command"].(string) command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string) purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string) declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
if targetSlug == "" || command == "" { if targetSlug == "" || command == "" {
return textResult("error: target and command are required"), nil return textResult("error: target and command are required"), nil
} }
@@ -463,7 +386,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
} }
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk), nil return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
}) })
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.", register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
@@ -550,29 +473,50 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, left(input_summary, 200) AS input_summary, entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary, left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id duration_ms, token_count, success, correlation_id
FROM agent_activity FROM agent_activity
WHERE agent_id = $1 WHERE agent_id = $1
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
}) })
// ─── Phase 5: operational MCP tools ────────────────────────────── // ─── Phase 5: operational MCP tools ──────────────────────────────
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state", register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
InputSchema: objSchema(), InputSchema: objSchema(
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` state, _ := argsMap(req)["state"].(string)
var statePtr *string
if state != "" {
statePtr = &state
}
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
e.attributes->>'lan_ip' AS lan_ip, e.attributes->>'lan_ip' AS lan_ip,
st.health, st.last_check_at e.state,
st.health, st.last_check_at,
(SELECT MAX(k.created_at)
FROM relationships r
JOIN knowledge_entities k ON k.entity_id = r.source_id
WHERE r.target_id = e.id
AND r.type = 'about'
AND r.valid_to IS NULL
AND (k.tags @> ARRAY['audit']::text[]
OR k.tags @> ARRAY['update']::text[]
OR k.title ILIKE '%audit%'
OR k.title ILIKE '%update%')
) AS last_audited_at
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type = 'lxc' WHERE e.type = 'lxc'
ORDER BY (e.attributes->>'pve_id')::int`), nil AND ($1::text IS NULL OR e.state = $1)
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
}) })
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
@@ -712,7 +656,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult("error: hostname required"), nil return textResult("error: hostname required"), nil
} }
slug := "ws:" + hostname slug := "ws:" + hostname
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -722,7 +666,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY e.slug`, slug), nil ORDER BY e.slug`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
@@ -733,7 +677,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
if slug == "" { if slug == "" {
return textResult("error: service_slug required"), nil return textResult("error: service_slug required"), nil
} }
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -741,7 +685,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
COALESCE(e.attributes::text, '{}') AS attrs COALESCE(e.attributes::text, '{}') AS attrs
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1`, slug), nil WHERE e.slug = $1`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
@@ -779,7 +723,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
limit := int(getFloat(args, "limit", 20)) limit := int(getFloat(args, "limit", 20))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
al.action, al.method, al.path, al.action, al.method, al.path,
al.detail::text AS details al.detail::text AS details
@@ -787,13 +731,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = al.entity_id JOIN entities e ON e.id = al.entity_id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY al.ts DESC ORDER BY al.ts DESC
LIMIT $2`, slug, limit), nil LIMIT $2`, slug, limit), "change_log"), nil
}) })
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
InputSchema: objSchema(), InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state, SELECT e.slug, e.type, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check COALESCE(st.last_check_at::text, '') AS last_check
@@ -803,7 +747,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
OR st.health IS NOT NULL OR st.health IS NOT NULL
ORDER BY st.health, e.slug ORDER BY st.health, e.slug
LIMIT 200 LIMIT 200
`), nil `), "fleet_snapshot"), nil
}) })
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
@@ -869,12 +813,18 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
correlationID := uuid.New().String() correlationID := uuid.New().String()
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
_, logErr := pool.Exec(ctx, ` _, logErr := pool.Exec(ctx, `
INSERT INTO agent_activity INSERT INTO agent_activity
(agent_id, activity_type, tool_name, input_summary, output_summary, (agent_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id) duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, "tool_call", toolName, inputSummary, outputSummary, agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
duration, success, correlationID) duration, success, correlationID)
if logErr != nil { if logErr != nil {
slog.Warn("mcp: log agent_activity", "error", logErr) slog.Warn("mcp: log agent_activity", "error", logErr)
@@ -884,6 +834,37 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
} }
} }
// entityArgKeys lists tool-argument keys, in priority order, that commonly
// carry the target entity's slug or UUID. Tool input schemas aren't
// consistent about naming this (target, entity_slug, slug, service_slug,
// lxc_slug, entity_id all appear across server.go's tool registrations), so
// this is a best-effort lookup used to tag agent_activity rows with the
// entity a tool call acted on.
var entityArgKeys = []string{
"target", "entity_slug", "slug", "slug_or_id",
"service_slug", "lxc_slug", "entity_id", "about",
}
// resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity.
func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID {
for _, key := range entityArgKeys {
v, _ := args[key].(string)
if v == "" {
continue
}
if u, err := uuid.Parse(v); err == nil {
return u
}
var id uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
return id
}
}
return uuid.Nil
}
// ─── Helpers ────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────
func argsMap(req *mcp.CallToolRequest) map[string]any { func argsMap(req *mcp.CallToolRequest) map[string]any {
@@ -992,6 +973,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
return textResult(string(data)) return textResult(string(data))
} }
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
if len(result.Content) == 0 {
return result
}
tc, ok := result.Content[0].(*mcp.TextContent)
if !ok || tc.Text == "" {
return result
}
var items []map[string]any
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
return result
}
wrapper := map[string]any{
"__renderer": rendererID,
"data": items,
}
data, _ := json.MarshalIndent(wrapper, "", " ")
return textResult(string(data))
}
// ─── SSH helpers ───────────────────────────────────────────────────────── // ─── SSH helpers ─────────────────────────────────────────────────────────
var ( var (
@@ -1067,6 +1068,21 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
} }
done := make(chan result, 1) done := make(chan result, 1)
go func() { go func() {
// Recovers a panic in CombinedOutput (SSH library internals, rare but
// not impossible) and reports it as a failed command instead of
// crashing the whole api process — every gated action runs through
// this function, so an unrecovered panic here would take down every
// concurrently-running task's execution, not just this one. Without
// this, a panic would ALSO silently degrade to "wait out the full
// timeout" (done never receives, the select below falls through to
// its time.After case) rather than crashing outright — recovering
// and sending an immediate result is strictly better: the caller
// finds out now, not after sshExecTimeout.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command) out, err := session.CombinedOutput(command)
done <- result{out, err} done <- result{out, err}
}() }()
@@ -1245,11 +1261,25 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
// fleet's reverse proxy) executed instantly with no approval at all. Routing // fleet's reverse proxy) executed instantly with no approval at all. Routing
// every mutating path through the same classifier + approval-queue logic // every mutating path through the same classifier + approval-queue logic
// closes that gap without special-casing each caller. // closes that gap without special-casing each caller.
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk string) *mcp.CallToolResult { func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
riskClass := policy.ClassifyCommand(command, declaredRisk) riskClass := policy.ClassifyCommand(command, declaredRisk)
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose}) runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
actionCol := "run:" + string(runParams) actionCol := "run:" + string(runParams)
// P1 plan-first gate: every task must propose a plan before any `run`,
// read-only or not. The only carve-out is a pure-DB Q&A that calls no
// `run` at all (those never reach this code path). Without this gate the
// SOUL.md "MANDATORY TASK FLOW" is unenforceable prose — weaker models
// skip propose_plan and go straight to run, leaving the operator with
// 23 individual approvals and no plan to approve (the original
// anti-pattern the flow exists to prevent). Mirrors D.1's structural
// refusal pattern in complete_task. sessionID == "" means a direct MCP
// call with no nomos session (e.g. an external script) — gate is a
// no-op there, since there's no session to hold a plan.
if sessionID != "" && !sessionHasPlan(ctx, pool, sessionID) {
return textResult("No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists.")
}
// Dedup: an identical pending command (same target, command, and // Dedup: an identical pending command (same target, command, and
// purpose) blocks a re-request — stops a tool-calling loop from queuing // purpose) blocks a re-request — stops a tool-calling loop from queuing
// the same approval repeatedly. // the same approval repeatedly.
@@ -1265,6 +1295,26 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID)) return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
} }
// P5: if this is a config_mutation command, no assent window is active,
// and there's already a pending_approval for this session, refuse —
// don't queue a second approval. The operator should see ONE approval
// (the plan), approve it (which opens the assent window), and then all
// subsequent config_mutation commands auto-run. Without this gate, the
// agent queues N individual approvals before the operator can respond,
// flooding the chat with approval cards — confirmed in session 20757eb9
// (WhatsApp bridge: two approvals for what should have been one plan).
if riskClass == policy.RiskConfigMutation && sessionID != "" && !assentWindowActive(ctx, pool, agentID, sessionID) {
var anyPending int
pool.QueryRow(ctx, `
SELECT COUNT(*) FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
sessionID).Scan(&anyPending)
if anyPending > 0 {
return textResult("An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run.")
}
}
id, _ := uuid.NewV7() id, _ := uuid.NewV7()
correlationID := uuid.New().String() correlationID := uuid.New().String()
execName := "run on " + targetSlug + " (" + id.String() + ")" execName := "run on " + targetSlug + " (" + id.String() + ")"
@@ -1275,6 +1325,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
} }
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`, pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
id, targetID, actionCol, riskClass, correlationID, agentID) id, targetID, actionCol, riskClass, correlationID, agentID)
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
id, targetID)
if sessionID != "" {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
FROM entities t WHERE t.slug = $2
AND NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
}
if riskClass == policy.RiskReadOnly { if riskClass == policy.RiskReadOnly {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug) host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
@@ -1296,8 +1363,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// re-approval. This is the "approve the plan, carry it out" path — the // re-approval. This is the "approve the plan, carry it out" path — the
// operator approved the overall direction; individual config steps // operator approved the overall direction; individual config steps
// within the window don't each need a separate yes. Destructive // within the window don't each need a separate yes. Destructive
// commands never auto-run, regardless of window. // commands never auto-run, regardless of window. (The old plan-window
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) { // path that opened on set_goal/propose_plan was removed — it opened
// before approval, letting config_mutation auto-run with zero operator
// consent. The assent window, opened only on operator approval, is the
// sole gate for config_mutation auto-run.)
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug) host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil { if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error())) pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
@@ -1319,7 +1390,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// recovery (e.g. a failed destroy needing stop, then destroy) so the // recovery (e.g. a failed destroy needing stop, then destroy) so the
// operator isn't asked to re-type "I confirm" for every single command // operator isn't asked to re-type "I confirm" for every single command
// against the thing they just confirmed. // against the thing they just confirmed.
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) { if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug) host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil { if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error())) pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
@@ -1386,19 +1457,54 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
} }
} }
// planWindowActive was removed 2026-07-15: it opened on set_goal and
// propose_plan, letting config_mutation auto-run before operator approval.
// The assent window (opened only on approval in agent.go) is the sole gate
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
// check used by the P1 plan-first gate.
// sessionHasPlan reports whether this nomos session has any plan step on
// record (any generation, any status). Used by the P1 plan-first gate in
// classifyAndGate to refuse `run` before `propose_plan` has been called.
// A `replaced` step (from a prior plan generation that was superseded by a
// follow-up sub-task — see store.reopenSession) still counts: it proves the
// agent once framed a plan for this session, and the reopen path guarantees a
// fresh `propose_plan` will run before the next `run` anyway. Fails closed
// (returns true) when the query errors so a transient DB issue doesn't block
// an otherwise-valid run.
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
if sessionID == "" {
return true // no session → no gate (direct MCP call from a script)
}
var count int
if err := pool.QueryRow(ctx,
`SELECT COUNT(*) FROM session_plan_steps WHERE session_id = $1`,
sessionID).Scan(&count); err != nil {
return true // fail open on DB error — don't block work over a flake
}
return count > 0
}
// assentWindowActive checks whether the operator has recently approved a plan // assentWindowActive checks whether the operator has recently approved a plan
// in this agent's chat session. The agent sets an assent_window.agent:<uuid> // in THIS TASK's chat session. The agent sets an
// key in autonomy_settings with an expiry timestamp when chat-assent grants // assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
// a pending execution. While active, config_mutation commands auto-run // expiry timestamp when chat-assent grants a pending execution. While
// without re-approval — the operator approved the overall plan, not each step. // active, config_mutation commands auto-run without re-approval — the
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) bool { // operator approved the overall plan, not each step. Scoped by session, not
if agentID == uuid.Nil { // just agent: with one agent:nomos entity serving every concurrent task, an
return false // agent-only key would let approving Task A's plan silently auto-run
// unapproved actions from a concurrently-running Task B. sessionID comes
// from the `_session_id` nomos injects into every tool call's wire args
// (never part of any tool's declared InputSchema, so the model never
// supplies or sees it) — see cmd/nomos/agent.go's tool dispatch loop.
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, sessionID string) bool {
if agentID == uuid.Nil || sessionID == "" {
return false // fail closed: no session to scope to means no window
} }
var expiresStr string var expiresStr string
err := pool.QueryRow(ctx, err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1", "SELECT value FROM autonomy_settings WHERE key = $1",
"assent_window.agent:"+agentID.String()).Scan(&expiresStr) "assent_window.agent:"+agentID.String()+".session:"+sessionID).Scan(&expiresStr)
if err != nil { if err != nil {
return false return false
} }
@@ -1410,20 +1516,21 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b
} }
// destructiveWindowActive reports whether targetSlug has a live, explicitly- // destructiveWindowActive reports whether targetSlug has a live, explicitly-
// confirmed destructive grant for this agent. Key format // confirmed destructive grant for this agent WITHIN THIS SESSION/TASK. Key
// ("destructive_window.agent:<id>.target:<slug>") must match // format ("destructive_window.agent:<id>.target:<slug>.session:<id>") must
// cmd/nomos/store.go's openDestructiveWindow — both processes read/write the // match cmd/nomos/store.go's openDestructiveWindow — both processes
// same autonomy_settings row. Scoped to one target so a typed confirmation // read/write the same autonomy_settings row. Scoped to one target AND one
// for destroying container A can never be read as authorizing anything // session so a typed confirmation for destroying container A in task X can
// against container B. // never be read as authorizing anything against container A from a
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug string) bool { // different, concurrently-running task Y.
if agentID == uuid.Nil || targetSlug == "" { func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug, sessionID string) bool {
if agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return false return false
} }
var expiresStr string var expiresStr string
err := pool.QueryRow(ctx, err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1", "SELECT value FROM autonomy_settings WHERE key = $1",
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug).Scan(&expiresStr) "destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID).Scan(&expiresStr)
if err != nil { if err != nil {
return false return false
} }
@@ -1461,10 +1568,26 @@ func knowledgeSlug(kind, title string) string {
func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*mcp.CallToolResult, error) { func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*mcp.CallToolResult, error) {
title, _ := args["title"].(string) title, _ := args["title"].(string)
content, _ := args["content"].(string) content, _ := args["content"].(string)
about, _ := args["about"].(string)
tagsRaw, _ := args["tags"].(string) tagsRaw, _ := args["tags"].(string)
kind, _ := args["kind"].(string) kind, _ := args["kind"].(string)
// Normalize about: accept a single string slug or an array of slugs.
var aboutSlugs []string
switch v := args["about"].(type) {
case string:
if s := strings.TrimSpace(v); s != "" {
aboutSlugs = []string{s}
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
aboutSlugs = append(aboutSlugs, s)
}
}
}
}
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
content = strings.TrimSpace(content) content = strings.TrimSpace(content)
if title == "" || content == "" { if title == "" || content == "" {
@@ -1511,11 +1634,13 @@ func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*
return textResult(fmt.Sprintf("error writing knowledge: %v", err)), nil return textResult(fmt.Sprintf("error writing knowledge: %v", err)), nil
} }
// Link it to the entity it's about, if given and not already linked. // Link it to the entity(s) it's about, if given and not already linked.
linked := "" linked := ""
if about = strings.TrimSpace(about); about != "" { if len(aboutSlugs) > 0 {
var linkedSlugs []string
for _, slug := range aboutSlugs {
var targetID uuid.UUID var targetID uuid.UUID
if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", about).Scan(&targetID); qerr == nil { if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil {
pool.Exec(ctx, ` pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now() SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
@@ -1523,9 +1648,13 @@ func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*
SELECT 1 FROM relationships SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`, WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
docID, targetID) docID, targetID)
linked = " and linked to " + about linkedSlugs = append(linkedSlugs, slug)
} else { }
linked = fmt.Sprintf(" (note: entity %q not found, saved unlinked)", about) }
if len(linkedSlugs) == 1 {
linked = " and linked to " + linkedSlugs[0]
} else if len(linkedSlugs) > 1 {
linked = fmt.Sprintf(" and linked to %d entities", len(linkedSlugs))
} }
} }

View File

@@ -1,11 +1,79 @@
package mcp package mcp
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestAnnotateJSONResult(t *testing.T) {
// valid JSON array → wrapped with __renderer + data
result := textResult(`[{"slug": "host:hubris", "type": "host"}]`)
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 1 {
t.Fatalf("expected 1 content item, got %d", len(annotated.Content))
}
tc, ok := annotated.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("content is not TextContent")
}
var wrapper map[string]interface{}
if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
if wrapper["__renderer"] != "entity_card" {
t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"])
}
data, ok := wrapper["data"].([]interface{})
if !ok || len(data) != 1 {
t.Fatal("data is not the original array")
}
}
func TestAnnotateJSONResultNoop(t *testing.T) {
// empty content → no-op
result := &mcp.CallToolResult{Content: []mcp.Content{}}
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 0 {
t.Fatal("empty content should be unchanged")
}
// non-JSON text → no-op (not wrapped)
result = textResult("just plain text")
annotated = annotateJSONResult(result, "entity_card")
tc, _ := annotated.Content[0].(*mcp.TextContent)
if strings.Contains(tc.Text, "__renderer") {
t.Fatal("non-JSON content should not be annotated")
}
// textResult with empty string → no-op
result = textResult("")
annotated = annotateJSONResult(result, "entity_card")
tc, _ = annotated.Content[0].(*mcp.TextContent)
if tc.Text != "" {
t.Fatal("empty text content should be unchanged")
}
}
func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) {
result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`)
annotated := annotateJSONResult(result, "lxc_list")
tc, _ := annotated.Content[0].(*mcp.TextContent)
var wrapper map[string]interface{}
json.Unmarshal([]byte(tc.Text), &wrapper)
data := wrapper["data"].([]interface{})
if len(data) != 3 {
t.Fatalf("expected 3 rows in data, got %d", len(data))
}
}
// TestNewServerRegistersTools verifies every tool registers with a valid // TestNewServerRegistersTools verifies every tool registers with a valid
// input schema. The MCP SDK panics at AddTool if a tool omits its object // input schema. The MCP SDK panics at AddTool if a tool omits its object
// input schema, so merely constructing the server exercises that contract — // input schema, so merely constructing the server exercises that contract —

View File

@@ -69,11 +69,14 @@ var destructivePatterns = []*regexp.Regexp{
var readOnlyLeadPattern = regexp.MustCompile( var readOnlyLeadPattern = regexp.MustCompile(
`^(cat|less|head|tail|ls|stat|file|du|df|free|uptime|uname|hostname|whoami|id|ip|ss|netstat|ping|` + `^(cat|less|head|tail|ls|stat|file|du|df|free|uptime|uname|hostname|whoami|id|ip|ss|netstat|ping|` +
`journalctl|dmesg|ps|top|htop|env|printenv|echo|which|whereis|` + `journalctl|dmesg|ps|top|htop|env|printenv|echo|which|whereis|` +
`grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|` + `grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|find|tree|locate|` +
`dpkg\s+(-l|-s|--list|--status)\b|apt\s+(list|search|show)\b|` + `dpkg\s+(-l|-s|--list|--status)\b|apt\s+(list|search|show)\b|` +
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units)|` + `systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` +
`timedatectl|hostnamectl|systemd-analyze|` +
`docker\s+(ps|images|inspect|logs|version|info|stats)|` + `docker\s+(ps|images|inspect|logs|version|info|stats)|` +
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` + `pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
`git\s+(status|log|diff|show|branch|remote)|` + `git\s+(status|log|diff|show|branch|remote)|` +
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`) `curl\s+-.*-I\b|curl\s+.*--head\b)\b`)

View File

@@ -15,6 +15,23 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
"git status", "git status",
"sudo cat /var/log/syslog", "sudo cat /var/log/syslog",
"ip a", "ip a",
// P4: newly added read-only verbs.
"find /var/log/rclone-backup/ -name runs.jsonl",
"tree /etc/caddy",
"locate Caddyfile",
"systemctl list-timers --all",
"systemctl list-units --type=service",
"systemctl list-unit-files --state=enabled",
"systemctl show caddy",
"timedatectl",
"hostnamectl",
"systemd-analyze blame",
"rclone lsl proton:library-backup",
// docker compose read-only subcommands (F1 fix).
"docker compose logs --tail=100",
"docker compose ps",
"docker compose top",
"docker compose config",
} }
for _, c := range cases { for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly { if got := ClassifyCommand(c, ""); got != RiskReadOnly {
@@ -99,6 +116,9 @@ func TestClassifyCommand_CompoundReadOnly(t *testing.T) {
"docker ps | grep caddy", "docker ps | grep caddy",
"systemctl status caddy 2>&1; journalctl -u caddy -n 5 --no-pager", "systemctl status caddy 2>&1; journalctl -u caddy -n 5 --no-pager",
"sudo systemctl status caddy; sudo journalctl -u caddy -n 5", "sudo systemctl status caddy; sudo journalctl -u caddy -n 5",
// P4: the exact compound from session d0d562e0 — find + ls + tail +
// echo + journalctl, all read-only segments.
"ls -lt /var/log/rclone-backup/ | head -20 && tail -3 /var/log/rclone-backup/runs.jsonl || echo \"not found\" && find /var/log/rclone-backup/ -name 'runs.jsonl'",
} }
for _, c := range cases { for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly { if got := ClassifyCommand(c, ""); got != RiskReadOnly {

35
internal/safego/safego.go Normal file
View File

@@ -0,0 +1,35 @@
// Package safego provides a goroutine launcher that recovers panics instead
// of letting them crash the whole process.
//
// Go's default behavior for a panic in ANY goroutine — not just the one
// serving an HTTP request, which net/http recovers automatically per
// request — is to take down the entire process. This codebase runs several
// long-lived or unattended background goroutines (the nomos auto-
// continuation worker, resumed chat turns, async execution dispatch, the SSE
// event listener) that do real work — JSON parsing of model/tool output,
// map/slice indexing — with no operator watching. Before this package, a
// single edge case in any of them (a malformed tool result, an unexpected
// nil) would crash nomos or the api process outright, taking down every
// concurrently-running task or request, not just the one that hit it.
package safego
import (
"log/slog"
"runtime/debug"
)
// Go runs fn in a new goroutine. A panic inside fn is recovered and logged
// (with a stack trace) instead of crashing the process. label identifies the
// goroutine in logs — use something a reader can trace back to the call
// site, e.g. "nomos:continuation-worker" or "mcp:executeApprovedViaAPI".
func Go(label string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("panic recovered in background goroutine",
"goroutine", label, "panic", r, "stack", string(debug.Stack()))
}
}()
fn()
}()
}

View File

@@ -0,0 +1,39 @@
package safego
import (
"sync"
"testing"
)
// TestGo_RecoversPanic is the concrete proof for the B1 fix in
// plans/2026-07-11-nomos-agent-code-review.md: a panic inside a goroutine
// launched via Go must not crash the process (or, here, the test binary —
// the same guarantee). Before this package existed, every background
// goroutine in cmd/nomos/internal/mcp/internal/httpapi used a bare `go`
// statement; an unhandled panic in any of them takes down the entire Go
// process, not just that goroutine.
func TestGo_RecoversPanic(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
Go("test:deliberate-panic", func() {
defer wg.Done()
panic("this must be recovered, not crash the test binary")
})
// If the panic weren't recovered, the whole test binary would crash
// before ever reaching this line (a Go panic in any goroutine terminates
// the process, full stop) — Wait() returning normally IS the proof.
wg.Wait()
}
// TestGo_RunsFnNormally confirms the non-panic path still just runs fn.
func TestGo_RunsFnNormally(t *testing.T) {
done := make(chan bool, 1)
Go("test:normal", func() {
done <- true
})
if !<-done {
t.Fatal("fn did not run")
}
}

View File

@@ -0,0 +1,53 @@
-- 018_tasks.up.sql
-- Elevate a chat session into a "task": a goal-structured unit of work with a
-- lifecycle status, an outcome, and a one-line summary — the first-class object
-- the task board and the live context panel render. See
-- plans/2026-07-11-goal-oriented-chat-control-panel.md.
--
-- entity_id links the session to its OWN entity (type 'task', registered in
-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off
-- the existing relationships graph unchanged — get_relations and
-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's
-- decoupling): a race between task-entity creation and the session insert must
-- not be able to break the session.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID;
-- Ordered plan steps. A step is a described unit of work that maps to a
-- run/request_execution call (no fixed step enum, per general-gated-execution).
-- execution_id is the gated action a step runs, if any; its terminal status
-- auto-closes the step server-side.
CREATE TABLE IF NOT EXISTS session_plan_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
seq INT NOT NULL,
title TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | running | done | failed | skipped | blocked
execution_id UUID,
target_slug TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq);
-- Structured decisions the agent surfaces to the operator mid-task. context
-- carries { entities:[], options:[], why:"" } for the pinned question card.
CREATE TABLE IF NOT EXISTS session_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
answer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_questions_session_open
ON session_questions(session_id) WHERE status = 'open';

View File

@@ -0,0 +1,10 @@
-- 019_task_completion_nudges.up.sql
-- See plans/2026-07-11-task-completion-safety-net.md (fix 2+3): a
-- goal-bearing session (set_goal was called, so it's a real structured
-- task, not the trivial-Q&A case handled by the inline safety net) can
-- still stall without ever calling complete_task. completion_nudges tracks
-- how many times the idle sweep has already nudged a stalled session, so it
-- can tell "never nudged" (nudge it) from "nudged once already, still
-- stuck" (auto-close it) rather than nudging forever.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS completion_nudges INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,17 @@
-- 020_session_reliability.up.sql
-- Plan step generation tracking + audit log session linkage.
-- See plans/2026-07-14-session-reliability-and-ux-audit.md.
-- Plan step generation: when the agent revises a plan mid-flight, new steps
-- get a higher generation number so the frontend can group/collapse old ones.
ALTER TABLE session_plan_steps ADD COLUMN IF NOT EXISTS generation INTEGER NOT NULL DEFAULT 1;
-- Track which session produced each audit-log entry so per-session analysis
-- (e.g. "did this task call update_entity_attributes?") is O(1) instead of
-- scanning the full log.
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS session_id UUID;
CREATE INDEX IF NOT EXISTS idx_audit_log_session ON audit_log (session_id);
-- Efficient lookup of pending-approval executions by session.
CREATE INDEX IF NOT EXISTS idx_nomos_plan_executions_session
ON nomos_plan_executions (session_id) WHERE continued_at IS NULL;

View File

@@ -3,6 +3,81 @@
You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab
AI agent running in a Docker container on mac-mini. You operate on port 8092. AI agent running in a Docker container on mac-mini. You operate on port 8092.
## ⚠️ MANDATORY TASK FLOW — EVERY CHAT, NO EXCEPTIONS
You MUST follow this flow for EVERY user request. Skipping steps means 23
individual approval popups instead of one plan approval. Do not skip.
### 1. SET GOAL — `set_goal`
State what this task is trying to achieve in one sentence. Call this FIRST.
Examples: "Audit all LXCs for pending apt updates" or "Deploy immich on strong."
### 2. PRE-PLAN — gather information
Call ONLY read-only tools to understand what you're working with:
- `search_knowledge` + `get_entity_knowledge` — has a past task already solved this?
**Check the knowledge base BEFORE re-running fleet-wide work.** If a same-day
or recent knowledge entry answers the question, present it and propose a
refresh plan that touches only the high-risk targets — not the whole fleet.
Re-running `run` against every LXC when the answer is already in the knowledge
graph wastes executions and credits.
- `get_entity` / `list_lxcs(state="active")` / `get_health_summary` — current state
- `get_relations` + `get_blast_radius` — what depends on what
Do NOT call `run` during this phase. This is research, not execution.
### 3. PROPOSE PLAN — `propose_plan`
Call ONCE with EVERY step end-to-end. The LAST step MUST be:
"Write back: update_entity_attributes + create_relationship + upsert_knowledge"
Include target slugs on each step so the panel links them. If you omit the
writeback step, one is auto-appended.
### 4. GET APPROVAL — only if the plan has config_mutation/destructive steps
After proposing the plan, check the step risk classes:
- **All read-only plan?** No approval needed. Go straight to step 5 and
execute — read-only `run` commands auto-run immediately once a plan
exists. Do NOT stop and wait.
- **Any config_mutation or destructive step?** END YOUR TURN. Do not call
`run`. Wait for the operator to approve. Approval vocabulary: "approved",
"yes", "go", "proceed", "continue", "ok", "go ahead". The assent window
then auto-approves subsequent config_mutation commands.
### 5. EXECUTE — `run` calls
Advance each step with `update_plan_step` (running → done) + `run`. Do NOT
call `propose_plan` again — it is refused once a step has started.
Read-only commands auto-run (no approval). Config_mutation commands
auto-run under the assent window (after approval). Destructive commands
always need explicit typed confirmation.
### 6. WRITE BACK + COMPLETE — `complete_task`
Call `update_entity_attributes` for every entity you ran `run` against
(versions, states, counts, timestamps). Call `create_relationship` for any
edge you discovered. Then `upsert_knowledge` for the narrative (pass `about`
as an array of entity slugs). Then `complete_task` with the outcome.
`complete_task` with `outcome=success` is **REFUSED** if you ran `run` but
didn't call `update_entity_attributes`/`create_relationship` — the knowledge
graph drifts without writeback. The ONLY carve-out from the writeback gate
is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
search_knowledge): answer directly, `complete_task` with a one-line summary,
no writeback needed.
### 7. ITERATE — follow-ups reopen the task
A `complete_task` is not the end of the conversation. If the operator sends
a follow-up on a completed session — e.g. "now look into the X you flagged"
or "fix that" — the session is reopened (status flips back to `executing`,
the prior plan is marked `replaced`). Treat the follow-up as a NEW sub-task:
call `set_goal` with the new goal, `propose_plan` a fresh plan (a new
generation — the panel will show it as a new list), execute, write back,
`complete_task`. Do NOT re-open or re-advance the old plan's steps.
**Anti-patterns (DO NOT DO):**
- Call `run` 23 times without `propose_plan` → 23 individual approval popups.
- Call `propose_plan` again after a step has started → refused; advance with
`update_plan_step` + `run` instead.
- Re-execute work when the operator points out a UI/sidebar inconsistency →
fix the display with `update_plan_step` (reconcile step states) or summarize
the panel in your reply. Never re-run `run` just to fix a display mismatch.
- Re-run a fleet-wide audit when a same-day knowledge entry already has the
answer → present the existing knowledge, propose a targeted refresh only.
## Source of truth ## Source of truth
The Oikos DB is the authoritative source for topology, service state, policy, The Oikos DB is the authoritative source for topology, service state, policy,
@@ -46,6 +121,24 @@ classifier will catch a genuinely dangerous command regardless, but be honest
about risk in your `purpose` text; the operator is trusting your description about risk in your `purpose` text; the operator is trusting your description
of what a command does. of what a command does.
## Every chat is a task — and every task has a plan
Every non-trivial chat follows the MANDATORY TASK FLOW at the top of this
file. **`propose_plan` is mandatory for any task that calls `run`** — even a
read-only inspection question needs a one-step plan ("Inspect X, report,
write back"). The `run` handler enforces this structurally: it refuses to
execute without a plan on record. A one-step plan is fine for trivial
questions; the point is that the operator sees what you intend before you
touch a target, not that every question needs a 10-step ceremony.
The ONLY carve-out is a pure-DB Q&A that calls *no* `run` (only
get_entity / list_lxcs / search_knowledge / get_relations / etc.): answer
directly and `complete_task` with a one-line summary. Don't invent
attributes/relationships/knowledge that don't exist just to fill the step.
The loop scales down (one-step plan for a trivial question) — it doesn't
disappear.
## Key MCP tools ## Key MCP tools
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions) - `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
@@ -63,10 +156,10 @@ of what a command does.
can be multi-line), `purpose` (one sentence — the operator sees exactly this when can be multi-line), `purpose` (one sentence — the operator sees exactly this when
deciding). Auto-runs if read-only; otherwise queues for approval. See "Your deciding). Auto-runs if read-only; otherwise queues for approval. See "Your
capability is unlimited" above. capability is unlimited" above.
- `request_execution` — curated fast-paths for common named actions: restart, systemctl - `run` — the ONLY mutation tool. Accepts `target`, `command`, `purpose`,
(enable/disable/reload), pct_exec (shell command inside an existing LXC), apt_upgrade `declared_risk`. The `request_execution` fixed-enum tool is RETIRED
(audit/upgrade), pct_create (provision a new LXC). Use these when they fit; use `run` (2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
for everything else — you do not need a matching named action to act. pct create, any shell command. There is no named-action tool anymore.
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text. - `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
You CAN read the internet with this. When asked to deploy a service from a URL or repo, You CAN read the internet with this. When asked to deploy a service from a URL or repo,
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
@@ -96,7 +189,7 @@ of what a command does.
## Policy awareness ## Policy awareness
Before calling `request_execution`: Before calling `run`:
- Check risk class via `get_entity` on the target - Check risk class via `get_entity` on the target
- `pct_create``config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing - `pct_create``config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing
more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container
@@ -171,7 +264,7 @@ note — continue executing the full plan from there. Do not re-request the same
action; check `get_execution_status` if you need the outcome. One approval per action; check `get_execution_status` if you need the outcome. One approval per
action is enough. action is enough.
**When proposing a plan, ALWAYS call `request_execution`/`run` in the same **When proposing a plan, ALWAYS call `run` in the same
turn.** Do not propose a plan in text, ask "shall I proceed?", and wait. turn.** Do not propose a plan in text, ask "shall I proceed?", and wait.
Call the tool — if it queues for approval, present what's queued and stop. Call the tool — if it queues for approval, present what's queued and stop.
The operator's "proceed"/"go ahead" will grant it and open the assent window. The operator's "proceed"/"go ahead" will grant it and open the assent window.
@@ -249,9 +342,9 @@ must state the result plainly: what's now true, what you verified, what (if
anything) failed or remains. Don't end a turn silently or with just a tool anything) failed or remains. Don't end a turn silently or with just a tool
call and no summary — the operator can't see the tools working the way you call and no summary — the operator can't see the tools working the way you
can, and a turn that ends without a status report reads as "nothing happened." can, and a turn that ends without a status report reads as "nothing happened."
When the whole goal is done and verified, say so explicitly and — if you When the whole goal is done and verified, say so explicitly, `upsert_knowledge`
learned anything non-obvious getting there — `upsert_knowledge` it before you anything non-obvious you learned, and call `complete_task` with the outcome and
sign off. a one-line summary so the task board reflects the real result.
## Skills ## Skills

View File

@@ -7,8 +7,9 @@
## Overview ## Overview
Standard operating procedures for the Nomos agent managing the hubris Standard operating procedures for the Nomos agent managing the hubris
homelab. All mutations route through `request_execution` → Oikos policy homelab. All mutations route through `run` → Oikos policy
gating → actuator (SSH). gating → actuator (SSH). The `request_execution` fixed-enum tool was retired
2026-07-14.
## Procedures ## Procedures
@@ -22,13 +23,13 @@ gating → actuator (SSH).
### Signal response ### Signal response
- `reversible_low` with validated pattern → `request_execution` (auto-restart) - `reversible_low` with validated pattern → `run` (auto-restart)
- `config_mutation` or `destructive` → escalate to operator - `config_mutation` or `destructive` → escalate to operator
- Repeated flapping → escalate with flap count - Repeated flapping → escalate with flap count
### Execution tracking ### Execution tracking
1. `request_execution` returns a correlation_id 1. `run` returns the execution ID in its result text
2. Poll `get_event_timeline` filtering by correlation_id 2. Poll `get_event_timeline` filtering by correlation_id
3. Once complete, `get_health_summary` to verify recovery 3. Once complete, `get_health_summary` to verify recovery
4. Record outcome via internal reasoning 4. Record outcome via internal reasoning
@@ -41,7 +42,9 @@ gating → actuator (SSH).
## Changelog ## Changelog
### 2026-07-08 — rename to Nomos ### 2026-07-14 — request_execution retired
All references to `request_execution` replaced with `run`. The fixed-enum
tool is no longer registered; agents use `run` for all mutations.
Agent renamed from Hermes to Nomos (N0 milestone). Agent renamed from Hermes to Nomos (N0 milestone).
### 2026-07-07 — initial Phase 4 skill ### 2026-07-07 — initial Phase 4 skill

View File

@@ -1,237 +0,0 @@
#!/usr/bin/env python3
"""
Generate knowledge/wiki/infrastructure/topology.md (Mermaid views) and per-entity context
cards from inventory.yaml.
Views:
1. Compute & ingress — hypervisors → guests → services → public URLs
2. Storage — mounts and pools per guest
Context cards (oikos/cards/<name>.md): one compact (~30-line) file per
host and service — identity, ontology edges, safe actions + risk class,
doc pointer, recent ledger history. This is the token-efficiency layer:
an agent orienting on an entity reads one card instead of several
search_docs/get_page round-trips.
Run from the repo root:
python3 oikos/gen-topology.py # writes topology.md + cards/
python3 oikos/gen-topology.py --check # exit 1 if output would change
Wired into the same regeneration path as mcp/build_host_files.py so the
diagrams and cards never drift from inventory. Edges follow
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import gen_topology_lib as lib # noqa: E402
from oikos import ledger as oikos_ledger # noqa: E402
from oikos import policy as oikos_policy # noqa: E402
from oikos import relations as oikos_relations # noqa: E402
INVENTORY = REPO / "inventory.yaml"
OUTPUT = REPO / "knowledge" / "wiki" / "infrastructure" / "topology.md"
CARDS_DIR = REPO / "oikos" / "cards"
BANNER = (
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\n"
)
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
# file's hyphenated name can't be). Re-exported here so existing call
# sites in this module don't need a rename.
node_id = lib.node_id
guest_label = lib.guest_label
compute_view = lib.compute_view
storage_view = lib.storage_view
archaeology_table = lib.archaeology_table
def _host_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (host:{name})\n"]
tag = "LXC" if entry.get("kind") == "lxc" else "VM" if entry.get("kind") == "vm" else entry.get("kind", "")
pve = entry.get("pve_id")
lines.append(f"- kind: {entry.get('kind', '?')}" + (f" ({tag} {pve})" if pve else ""))
lines.append(f"- state: {entry.get('state', 'active')}")
if entry.get("host"):
lines.append(f"- runs-on: host:{entry['host']}")
if entry.get("role"):
lines.append(f"- role: {entry['role']}")
addr = entry.get("lan_ip", "")
mesh = entry.get("mesh", {})
mesh_bits = []
for m, v in mesh.items():
if isinstance(v, dict) and (v.get("ip") or v.get("fqdn")):
mesh_bits.append(f"{m}:{v.get('fqdn') or v.get('ip')}")
if addr or mesh_bits:
lines.append(f"- address: {addr}" + (f" (mesh: {', '.join(mesh_bits)})" if mesh_bits else ""))
if entry.get("mounts"):
lines.append(f"- mounts: {', '.join(entry['mounts'])}")
doc = None
if entry.get("kind") == "lxc" and pve:
cand = REPO / "knowledge" / "wiki" / "containers" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "vm" and pve:
cand = REPO / "knowledge" / "wiki" / "vms" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "proxmox-host":
cand = REPO / "knowledge" / "wiki" / "hosts" / f"{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
if doc:
lines.append(f"- doc: {doc}")
if entry.get("age_pubkey"):
lines.append("- secrets: enrolled (age key present)")
rel = oikos_relations.relations(f"host:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
if rel["blast_radius"]:
lines.append(f"- full blast radius: {', '.join(rel['blast_radius'])}")
lines.append("\n## Safe actions")
lines.append("- see the services this host runs for action-level risk classes")
hist = oikos_ledger.history(f"host:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def _service_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (service:{name})\n"]
if entry.get("backend"):
lines.append(f"- backend: host:{entry['backend']}")
url = entry.get("url") or entry.get("endpoint")
if url:
lines.append(f"- url: {url}")
if entry.get("doc_page"):
lines.append(f"- doc: {entry['doc_page']}")
if entry.get("config_repo"):
lines.append(f"- config repo: {entry['config_repo']}")
if entry.get("risk_notes"):
lines.append(f"- risk notes: {entry['risk_notes']}")
rel = oikos_relations.relations(f"service:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
lines.append("\n## Safe actions")
for a in oikos_policy.safe_actions_for_service(name, entry):
lines.append(f"- {a['action']}{a['risk']} (approval: {a['approval']})")
hist = oikos_ledger.history(f"service:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def generate_cards(inv: dict) -> dict[Path, str]:
desired: dict[Path, str] = {}
for name, entry in inv.get("hosts", {}).items():
desired[CARDS_DIR / f"host-{name}.md"] = _host_card(name, entry, inv)
for name, entry in inv.get("services", {}).items():
if isinstance(entry, dict):
desired[CARDS_DIR / f"service-{name}.md"] = _service_card(name, entry, inv)
return desired
def write_cards(inv: dict, check: bool = False) -> int:
CARDS_DIR.mkdir(parents=True, exist_ok=True)
desired = generate_cards(inv)
diff_count = 0
for path, content in desired.items():
existing = path.read_text() if path.exists() else ""
if existing != content:
diff_count += 1
if not check:
path.write_text(content)
for existing_path in CARDS_DIR.glob("*.md"):
if existing_path not in desired:
diff_count += 1
if not check:
existing_path.unlink()
return diff_count
def render(inv: dict) -> str:
hosts = inv.get("hosts", {})
services = inv.get("services", {})
counts = (
f"{sum(1 for e in hosts.values() if e.get('kind') == 'proxmox-host')} hypervisors, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'lxc')} LXCs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'vm')} VMs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'workstation')} workstations, "
f"{len(services)} services"
)
parts = [
BANNER,
"# Topology (generated)\n",
f"Source: [inventory.yaml](../../../inventory.yaml) — {counts}.",
"Edge semantics: [oikos/ontology.yaml](../../../oikos/ontology.yaml). "
"Operating model: [OIKOS.md](../../../.agents/OIKOS.md).\n",
"## Compute & ingress\n",
"\n".join(compute_view(inv)) + "\n",
"## Storage (mounts)\n",
"\n".join(storage_view(inv)) + "\n",
]
arch = archaeology_table(inv)
if arch:
parts += ["## Archaeology (destroyed nodes)\n", "\n".join(arch) + "\n"]
return "\n".join(parts)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true",
help="exit 1 if output would change (don't write)")
args = parser.parse_args()
inv = yaml.safe_load(INVENTORY.read_text())
content = render(inv)
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
topology_changed = existing != content
card_diffs = write_cards(inv, check=args.check)
if args.check:
if topology_changed:
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
if card_diffs:
print(f"{card_diffs} card(s) in oikos/cards/ would change", file=sys.stderr)
return 1 if (topology_changed or card_diffs) else 0
if topology_changed:
OUTPUT.write_text(content)
print(f"wrote {OUTPUT.relative_to(REPO)}")
if card_diffs:
print(f"wrote/updated {card_diffs} card(s) in oikos/cards/")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,112 +0,0 @@
"""oikos/gen_topology_lib.py — shared Mermaid-view logic.
Split out of oikos/gen-topology.py so it's importable (a hyphenated
filename can't be `import`ed as a module). oikos/gen-topology.py is the
CLI entrypoint that writes knowledge/wiki/infrastructure/topology.md + oikos/cards/;
oikos/console/app.py imports this module directly to render the live
/graph page without shelling out.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
def load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def node_id(name: str) -> str:
"""Mermaid-safe node id."""
return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_")
def guest_label(name: str, entry: dict) -> str:
pve = entry.get("pve_id")
role = entry.get("role", "")
tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \
f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "")
ip = entry.get("lan_ip", "")
parts = [name, tag, role, ip]
return "<br/>".join(str(p) for p in parts if p)
def compute_view(inv: dict) -> list[str]:
hosts = inv.get("hosts", {})
services = inv.get("services", {})
lines = ["```mermaid", "flowchart LR"]
hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"}
guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")}
others = {n: e for n, e in hosts.items()
if e.get("kind") in ("workstation", "external")}
for hv in hypervisors:
lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]')
for g, e in guests.items():
if e.get("host") == hv:
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
lines.append(" end")
# guests without a parent hypervisor recorded (e.g. rclone)
for g, e in guests.items():
if e.get("host") not in hypervisors:
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
for n, e in others.items():
shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]"
lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}')
# ingress: public URL -> backend (routes-to)
for svc, e in sorted(services.items()):
if not isinstance(e, dict):
continue
backend = e.get("backend")
url = e.get("url") or (
f'https://{e["public_host"]}' if e.get("public_host") else None)
if backend and url and backend in hosts:
host = url.removeprefix("https://").removeprefix("http://")
# hypervisors are rendered as subgraphs; point edges at the subgraph id
target = node_id(backend) + ("_sub" if backend in hypervisors else "")
lines.append(
f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}')
lines.append("```")
return lines
def storage_view(inv: dict) -> list[str]:
hosts = inv.get("hosts", {})
lines = ["```mermaid", "flowchart LR"]
pools: set[str] = set()
edges: list[str] = []
for name, e in hosts.items():
for mount in e.get("mounts", []):
pools.add(mount)
edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}')
for pool in sorted(pools):
lines.append(f' {node_id(pool)}[("{pool}")]')
lines.extend(sorted(set(edges)))
lines.append("```")
return lines
def archaeology_table(inv: dict) -> list[str]:
arch = inv.get("archaeology", {})
if not arch:
return []
lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"]
entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")),
reverse=True)
for name, e in entries:
lines.append(
f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} '
f'| {e.get("reason", "")} |')
return lines

View File

@@ -1,6 +1,20 @@
# 2026-07-08 — Control room web UI # 2026-07-08 — Control room web UI
**Status:** In Progress — N0-N3 (Nomos amendment: chat home + sessions), M1 **Status:** In Progress (audited 2026-07-11 — still accurate; remaining gaps:
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
`relationship.ended` API calls don't emit `observability.Event`, and
trusted-proxy header auth for Authentik was never added to `combinedAuth`).
**Superseded (2026-07-12):** the embed architecture below (`go:embed
all:web/dist`, served at `/ui/`) was removed —
[2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md) Phase 0
separates the SPA from the `oikos` binary into a standalone static build,
served at `/` (no `/ui/` prefix), talking to the API over bearer-token
auth (the dev-open bypass mentioned nowhere in this plan was also removed).
The trusted-proxy-header gap noted above is moot under the new model — every
route requires a real bearer token regardless of what's in front of it. M1-M3
and the SPA/component work below are unaffected; only the packaging and auth
sections are stale.
N0-N3 (Nomos amendment: chat home + sessions), M1
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte (dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
component system), M2 (Operations ledger with approve/deny + cancel, Signals component system), M2 (Operations ledger with approve/deny + cancel, Signals
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with page with ack/resolve/mute, live nav badges), and M3 (graph explorer with

View File

@@ -1,6 +1,11 @@
# 2026-07-08 — Liveness, drift, and UX cohesion # 2026-07-08 — Liveness, drift, and UX cohesion
**Status:** In Progress — Phases 14 code complete; not yet deployed. Phase 5 deferred. **Status:** In Progress — Phases 14 code complete and now deployed
(re-verified 2026-07-12: mac-mini was redeployed from `main` that day for
unrelated auth work — plans/2026-07-12-wails-desktop-app.md — which carried
every commit up to that point, including this plan's, so "not yet deployed"
below is stale). Phase 5 deferred. (Audited 2026-07-11 — still accurate;
prompt caching within Phase 4 also confirmed not implemented.)
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution - **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
@@ -24,20 +29,19 @@
- **Phase 4 (agent efficiency):** core piece done — prior turns' tool - **Phase 4 (agent efficiency):** core piece done — prior turns' tool
calls/results are now replayed into the conversation (previously dropped calls/results are now replayed into the conversation (previously dropped
entirely), and a compact live fleet-health snapshot is injected into the entirely), and a compact live fleet-health snapshot is injected into the
system prompt each turn so the agent starts oriented. Prompt caching and system prompt each turn so the agent starts oriented. Prompt caching is
reconsidering the default model are **not done** (lower priority, no **not done** (lower priority, no measured regression without it).
measured regression without them). Reconsidering the default model — done, but not by this plan: switched to
`deepseek/deepseek-v4-pro` on 2026-07-10 (`cmd/nomos/agent.go:70`) for
reliability, per that commit's own comment ("the flash tier over-narrates,
occasionally emits canned refusals, and is unreliable at multi-step tool
use").
- **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API - **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API
(list/create/patch, including enable/disable) already existed server-side; (list/create/patch, including enable/disable) already existed server-side;
the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a
"run check now" endpoint (no scheduler on-demand entrypoint exists yet), "run check now" endpoint (no scheduler on-demand entrypoint exists yet),
relationship editing, and an entity attribute editor UI. relationship editing, and an entity attribute editor UI.
**Not yet deployed** — the live `oikos-api`/`oikos-scheduler`/nomos
containers still run the pre-fix binaries; rebuilding and restarting them
needs an explicit go-ahead since it touches the running homelab control
plane.
Addresses five felt problems with the current system: (1) the agent reports Addresses five felt problems with the current system: (1) the agent reports
stale machine state as if it were fresh, (2) sessions can't be opened and feel stale machine state as if it were fresh, (2) sessions can't be opened and feel
disconnected from chat, (3) the Nomos agent re-derives state every turn and disconnected from chat, (3) the Nomos agent re-derives state every turn and

View File

@@ -1,6 +1,24 @@
# 2026-07-08 — Oikos gaps, broken things, and improvements # 2026-07-08 — Oikos gaps, broken things, and improvements
**Status:** Planned **Status:** In Progress — audited 2026-07-11, re-audited 2026-07-12 for
drift from the `cmd/hermes``cmd/nomos` rename and later fixes. Done: A1
(approval FK bug), A3 (Hermes→Nomos help text), **Section C** (toy NLU /
silent-wrong-answer fallback — nomos now calls real `listTools()` and
routes unmatched queries to `/chat` instead of guessing, per
`cmd/nomos/main.go:444-465`), **D.5** (SOUL.md/actuator architecture
mismatch — `nomos/SOUL.md:21-22,40` now accurately documents SSH via the
`run` tool), D1 (`upsert_knowledge`), D4-partial (general `run` tool).
Still open: A2 (notifier flooding/dedup), A4 (`resolveHost` dead code), A5
(`queryRows` stringly-typed columns), A6 (stale `get_state_snapshot`
description), B1-B5 (enrollment auth, fake Infisical creds, `/query`
mesh-only auth unenforced, insecure host key checking, optional
`caller_pubkey`), D2/D3 (no `get_approval_status`/`list_pending_approvals`/
signal ack-resolve-mute tools), E-partial (Caddyfile placeholders still
present; tool count now 33, documented in AGENTS.md as of 2026-07-12).
2026-07-12 re-audit also refreshed every `cmd/hermes``cmd/nomos` and
`internal/mcp/server.go` line-number citation below (the file grew from 28
to 33 registered tools since 2026-07-11) — content/status of each finding
unchanged, only citations moved.
## Goal ## Goal
@@ -70,22 +88,24 @@ named `tools/list`, which doesn't exist. The correct `listTools()` helper
### A4. `resolveHost` never returns a per-entity SSH user ### A4. `resolveHost` never returns a per-entity SSH user
`internal/mcp/server.go:943` — the named return `sshUser` is always `""`; the `internal/mcp/server.go:1222` (was :943 — line moved) — the named return
per-entity user branch is dead and everything relies on `sshExec`'s global `sshUser` is always `""`; the per-entity user branch is dead and everything
default fallback. **Fix:** read the SSH user from entity attributes or delete relies on `sshExec`'s global default fallback. **Fix:** read the SSH user
the dead return to make the behavior honest. from entity attributes or delete the dead return to make the behavior
honest.
### A5. `queryRows` stringifies every column ### A5. `queryRows` stringifies every column
`internal/mcp/server.go:861` renders all values via `fmt.Sprintf("%v", ...)`, `internal/mcp/server.go:1090` (was :861 — line moved) renders all values via
so numbers, bools, timestamps, and JSON all reach agents as strings. `fmt.Sprintf("%v", ...)`, so numbers, bools, timestamps, and JSON all reach
**Fix:** type-preserving serialization (pass through pgx-native values into agents as strings. **Fix:** type-preserving serialization (pass through
`json.Marshal`) — improves every read tool at once. pgx-native values into `json.Marshal`) — improves every read tool at once.
### A6. `get_state_snapshot` description is stale ### A6. `get_state_snapshot` description is stale
`internal/mcp/server.go:689` still advertises "disk, drift count" — columns `internal/mcp/server.go:863` (was :689 — line moved) still advertises "disk,
removed in commit 3ea43ad. **Fix:** update the description. drift count" — columns removed in commit 3ea43ad. **Fix:** update the
description.
--- ---
@@ -93,12 +113,13 @@ removed in commit 3ea43ad. **Fix:** update the description.
### B1. Enrollment is unauthenticated, with a false comment ### B1. Enrollment is unauthenticated, with a false comment
`internal/httpapi/server.go:97` says "unauthenticated (IP-gated in handler)" `internal/httpapi/server.go:111` (was :97) says "unauthenticated (IP-gated
but `EnrollClient` (`internal/httpapi/impl.go:1099`) performs no IP check at in handler)" but `EnrollClient` (`internal/httpapi/impl.go:1166`, was
all — the only gate is the target entity being in state :1099) performs no IP check at all — the only gate is the target entity
`planned`/`provisioning`. Caddy's `@enroll` matcher bypasses Authentik. being in state `planned`/`provisioning`. Caddy's `@enroll` matcher bypasses
Anyone reaching `oikos.hubris.network` who knows (or guesses) a planned slug Authentik. Anyone reaching `oikos.hubris.network` who knows (or guesses) a
receives that node's **age private key** in the HTTP response body. planned slug receives that node's **age private key** in the HTTP response
body. Still open — line numbers only, substance unchanged.
**Fix:** enforce a real gate (mesh-CIDR check, one-time enrollment token **Fix:** enforce a real gate (mesh-CIDR check, one-time enrollment token
minted when the entity is created, or both), and stop returning the age minted when the entity is created, or both), and stop returning the age
@@ -106,34 +127,49 @@ private key in the response — have the client fetch it from the secret store.
### B2. Fake Infisical credentials returned to enrollees ### B2. Fake Infisical credentials returned to enrollees
`internal/httpapi/impl.go:1191-1192` returns `"inf_client_"+uuid` / `internal/httpapi/impl.go:1260-1261` (was :1191-1192) returns
`"inf_secret_"+uuid` — random strings wired to nothing. Enrolled clients hold `"inf_client_"+uuid` / `"inf_secret_"+uuid` — random strings wired to
credentials that authenticate against nothing. nothing. Enrolled clients hold credentials that authenticate against
nothing. Still open — line numbers only, substance unchanged.
**Fix:** implement `CreateMachineIdentity` in `internal/secrets/infisical.go`, **Fix:** implement `CreateMachineIdentity` in `internal/secrets/infisical.go`,
or return no credentials and document the manual step. or return no credentials and document the manual step.
### B3. Hermes `/query` has no auth ### B3. Nomos's `/query` has no auth
`hermes/config.yaml:9` sets `mesh_only: true` but `cmd/hermes/main.go` never `nomos/config.yaml:9` (was `hermes/config.yaml:9`) sets `mesh_only: true`
reads or enforces it — it serves any caller on :8092, who can invoke but `cmd/nomos/main.go` (was `cmd/hermes/main.go`) never reads or enforces
`request_execution`. **Fix:** enforce mesh-CIDR (or bearer token) in the it — it serves any caller on :8092, who can invoke `request_execution`.
handler; fail closed. Still open, now also tracked as C1 in
[2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md),
deferred by the operator. **Fix:** enforce mesh-CIDR (or bearer token) in
the handler; fail closed.
### B4. SSH host keys not verified ### B4. SSH host keys not verified
`ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:920`. `ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:1155` (was :920).
Still open — line number only, substance unchanged.
**Fix:** known_hosts pinning (keys are already inventory-managed per node). **Fix:** known_hosts pinning (keys are already inventory-managed per node).
### B5. `list_my_secrets` enumerates all node pubkeys ### B5. `list_my_secrets` enumerates all node pubkeys
Without `caller_pubkey`, `internal/mcp/server.go:709-720` returns every entity Without `caller_pubkey`, `internal/mcp/server.go:879-883` (was :709-720)
that has an `age_pubkey`; nothing ties the caller to what it may list. returns every entity that has an `age_pubkey`; nothing ties the caller to
what it may list. Still open — line numbers only, substance unchanged.
**Fix:** require `caller_pubkey` and scope results to the caller's **Fix:** require `caller_pubkey` and scope results to the caller's
entitlements. entitlements.
--- ---
## C. User perspective (interacting via Hermes) ## C. User perspective (interacting via Hermes) — RESOLVED
**Resolved as of the Hermes→Nomos rewrite (verified 2026-07-12).** This
entire section described `cmd/hermes`, which no longer exists — Hermes was
renamed and rebuilt as `cmd/nomos`, a real LLM-backed agent loop, which is
exactly the recommendation below. `cmd/nomos/main.go:444-465` now calls the
real `listTools()` for "help"/"what can you do", and routes unmatched
queries to "natural language queries belong to `/chat`..." instead of
silently falling back to `get_health_summary`. Kept below for history —
original text unchanged.
- `routeQuery` NLU is hardcoded `strings.Contains`; `extractEntity` - `routeQuery` NLU is hardcoded `strings.Contains`; `extractEntity`
(`cmd/hermes/main.go:173`) recognizes only 5 services (`authentik, caddy, (`cmd/hermes/main.go:173`) recognizes only 5 services (`authentik, caddy,
@@ -167,27 +203,41 @@ says 21 — both stale). Missing capabilities:
`pending_approval`, an agent has no way to check or reference the approval. `pending_approval`, an agent has no way to check or reference the approval.
Add `get_approval_status` / `list_pending_approvals`. Add `get_approval_status` / `list_pending_approvals`.
4. Execution actions limited to `restart | systemctl | pct_exec | 4. Execution actions limited to `restart | systemctl | pct_exec |
apt_upgrade` — no deploy/rollback/config-edit path. apt_upgrade` — no deploy/rollback/config-edit path. Partially
5. Architecture/doc mismatch: `hermes/SOUL.md` claims "no SSH access; all superseded: the general `run` MCP tool (D4-partial, done) covers
mutations flow through the actuator", but the MCP server runs arbitrary commands now; `request_execution`'s fixed enum is still there
`restart`/`pct_exec` synchronously over SSH from inside the api process for the specific actions it names (see
(`sshExec`, server.go:902). Align docs or move execution to the actuator. [2026-07-10-general-gated-execution.md](2026-07-10-general-gated-execution.md)).
5. **RESOLVED (verified 2026-07-12).** Architecture/doc mismatch:
`hermes/SOUL.md` claimed "no SSH access; all mutations flow through the
actuator", but the MCP server ran `restart`/`pct_exec` synchronously over
SSH from inside the api process. `nomos/SOUL.md:21-22,40` now accurately
documents SSH access via the policy-gated `run` tool — matches the
architecture the general-gated-execution plan built. No longer a
mismatch.
--- ---
## E. Doc drift / housekeeping ## E. Doc drift / housekeeping
- Tool counts: README 15 / AGENTS.md 21 / actual 28 — regenerate from - **RESOLVED (verified 2026-07-12):** Tool counts. README 15 / AGENTS.md 21
`internal/mcp/server.go` (consider a doc-gen make target). / actual 28 was already stale by 2026-07-11 (registered tools grew to
- `compose/caddy/Caddyfile.oikos` retains literal `<mac-mini-mesh-ip>` 33) — AGENTS.md now documents all 33 with the full catalog (2026-07-12).
placeholders in all three vhosts. - **Still open:** `compose/caddy/Caddyfile.oikos` retains literal
- `.agents/HERMES.md` lists "`inventory.yaml`, `inventory.yaml`" (duplicate). `<mac-mini-mesh-ip>` placeholders (this repo's copy is a reference only —
- `plans/index.md` drift: fix-MCP-tools row sat in Active with a broken link see [2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)'s
after the file moved to `done/` (fixed alongside this plan); TRMNL listed "Plan review" — the real config lives in `dtoro/caddy-conf`).
active though in `done/`; Grimmory header says `in-progress` though in - **RESOLVED:** `.agents/HERMES.md` renamed to `.agents/NOMOS.md`; the
`done/`; `.hermes/plans/` (7 executed plans) missing from disk. duplicate-line bug itself is still present at `.agents/NOMOS.md:11` —
- `plans/2026-07-05-oikos-prometheus-lxc.md` (~0% done) references deleted only the file citation was stale, the underlying nit is still open.
`oikos/scheduler.py` and `bin/homelab`; LXC 131 collision unresolved. - **RESOLVED (verified 2026-07-12):** `plans/index.md` drift — the broken
link, TRMNL/Grimmory Active/Done mismatch, and missing `.hermes/plans/`
entries described here are no longer present in the current
`plans/index.md`; already fixed sometime after this plan was written.
- **RESOLVED (verified 2026-07-12):** `plans/2026-07-05-oikos-prometheus-lxc.md`
already self-corrected both the deleted-file references and the LXC 131
collision in its own 2026-07-08 changelog — this bullet describes a
pre-fix state.
--- ---

View File

@@ -1,6 +1,17 @@
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated # 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
**Status:** Planned **Status:** In Progress — re-audited 2026-07-14. Done: `ClassifyCommand` risk
classifier, general `run` MCP tool, chat-assent approval (no button
required), blast radius on approval cards, session digest, global activity
feed (`Ops.svelte` "Executions" tab, risk-badged), Learning view
(success-rate trend), **and now the `request_execution` enum retirement**
(commit `60effcb`, 2026-07-14 — `run` is the only mutation tool; the legacy
handler functions are kept as reference only, with a "DO NOT re-register"
guard in `internal/mcp/server.go:360`). Still open: **revive auto-act**
`internal/actuator/actuator.go:~125` is still a literal
`{"success": true, "message": "stub execution"}` stub (item 10). The
`run`-gated path covers operator-initiated work end-to-end; auto-act is the
observe→Act direction (signals triggering actions), still unimplemented.
## Goal ## Goal

View File

@@ -0,0 +1,347 @@
# 2026-07-11 — Nomos agent code review: gaps and improvement plan
**Status:** In Progress — 2026-07-11. Every finding except C1 (A1-A3, B1-B3,
D1-D3, E, F1) is fixed, tested, and verified live against the running stack.
C1 (unauthenticated nomos gateway) is explicitly deferred per operator
instruction ("leave auth out for these round of fixes") — the one item
keeping this out of `done/`.
- A1 `3919ec3`, B1+B2 `c5ffaec`, A3 `926969a`, D1-D3 `76f7630`,
A2 `c390164`, B3 `6d4f6de`, F1 `11c18e8`.
- New `internal/safego` package (B1) and `cmd/nomos/store_test.go` (A2, plus
a regression test for the earlier plan-append fix) are the first automated
tests for any of this package's core logic — closing part of finding E,
though full coverage of agent.go/main.go remains future work.
- C1 remains open — nomos's gateway (port 8092) still has no authentication.
Revisit separately.
## Scope
A full read-through of `cmd/nomos/` (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go — 3,120 lines) plus targeted checks of its HTTP exposure,
goroutine safety, and test coverage. Every finding below is grounded in a
specific file:line or a runnable reproduction — two of the sharper ones
(A1, A2) were empirically confirmed with throwaway test probes before being
written up, not just read and assumed.
This is a review, not an implementation — findings are ranked by severity with
a proposed fix per item; nothing here has been changed yet.
---
## A. Correctness bugs (confirmed, not theoretical)
### A1. Chat-assent word matching has real substring false positives
[assent.go:73-103](../cmd/nomos/assent.go). `isAssent`/`isTypedConfirmation`
pad the message with spaces and word-boundary-check the **negation** list
(`strings.Contains(m, " "+w+" ")`), but the **assent**/**confirm** checks use
bare `strings.Contains(m, w)` — no word boundary at all. Confirmed live via a
test probe:
- `isAssent("not sure, maybe yesterday's logs show something useful")`
**`true`** (`"yes"` matches inside `"yesterday"`; `"not"` alone isn't in
`negationWords`, only the phrase `"not yet"` is).
- `isTypedConfirmation("I haven't confirmed anything yet, let me think")`
**`true`** (`"confirm"` matches inside `"confirmed"`; `"haven't"` isn't in
`negationWords`, which only has `"don't"`/`"do not"`, not other contracted
negatives).
The second one is the serious half: `isTypedConfirmation` is the **sole gate
for DESTRUCTIVE actions** ([agent.go:220-223](../cmd/nomos/agent.go)) — a
message that merely *mentions* not having confirmed something yet can read as
an explicit confirmation.
**Fix:** apply the same space-padded word-boundary check to the assent/confirm
word lists that negation already uses. Expand `negationWords` to cover
contracted negatives (`haven't`, `hasn't`, `isn't`, `wasn't`, `can't`,
`won't`, `not` as a standalone word, not just `"not yet"`). Add both
reproduced cases as permanent regression tests in `assent_test.go`.
### A2. Unbounded conversation history replay — no windowing, no token budget
[agent.go:185-207](../cmd/nomos/agent.go): every single turn (`chatWith`)
calls `a.store.getMessages(ctx, sessionID)` — [store.go:218-239](../cmd/nomos/store.go),
`SELECT ... WHERE session_id=$1 ORDER BY created_at ASC` with **no `LIMIT`,
no windowing, no summarization** — and replays the *entire* history into the
LLM call every time. `truncateToolResults` ([store.go:152-185](../cmd/nomos/store.go))
caps each individual tool **result** at 4KB, but caps nothing else: not tool
**args**, not the number of tool calls in one message, not the total message
count, not total tokens.
This isn't theoretical — an earlier production audit (see
[chat-sessions-improvements](done/2026-07-09-chat-sessions-improvements.md))
found a single turn with **70 tool calls** and messages up to **106KB**. Every
subsequent turn of a long-running or heavily-autonomous task (exactly what
auto-continuation is built for) re-sends that ever-growing history in full.
This is a real cost, latency, and eventual context-length-limit risk that
compounds specifically for the tasks the system is designed to run longest.
**Fix:** at minimum, cap replayed history to the most recent N messages or a
token budget, with older turns either dropped or collapsed into a short
system-message summary (`finalSummary`'s existing one-shot summarization
pattern, [agent.go:481-492](../cmd/nomos/agent.go), could be reused for this).
Needs a decision on where the cutoff lives (see open questions).
### A3. A live turn's tool-call history is lost entirely if the client disconnects mid-stream
[main.go handleChat](../cmd/nomos/main.go): `toolCalls`/`finalText` accumulate
only in local closure variables; `st.saveMessage(...)` runs exactly **once**,
after `a.chat(...)` returns, using `ctx := r.Context()` — the *same* context
that cancels the instant the client disconnects (Stop button, tab close,
network blip). If `a.chat` returns early because that context was cancelled,
the final `saveMessage` call runs with an already-cancelled context and its
error return is never checked — the whole turn's tool-call history (already
real: executions launched, knowledge possibly written) is silently lost from
the persisted transcript.
Contrast with `resumeSession`/`continueSession` ([continue.go:96-166](../cmd/nomos/continue.go)),
which insert a placeholder row immediately and update it after every single
tool call — exactly the incremental-persistence pattern `handleChat` lacks.
Verified live this session: my own Stop-button test showed the turn's actual
tool calls (6 of them) *were* visible in the UI only because the SSE stream
had already pushed them to the browser's in-memory store before the abort —
none of that would have survived a page reload, since nothing was persisted.
**Fix:** bring `handleChat` in line with `resumeSession`'s pattern — insert a
placeholder row before the turn starts, update it after each tool call using
a context *not* tied to the client connection for the write itself (or at
minimum, persist with `context.Background()` in a deferred cleanup so a
cancelled request context doesn't take the DB write down with it).
---
## B. Robustness
### B1. Zero panic recovery on any background goroutine
Every explicitly-spawned goroutine across the agent surface has no
`recover()`:
```
cmd/nomos/main.go:78 go nAgent.runContinuationWorker(ctx)
cmd/nomos/main.go:80 go func() { ...sweep ticker... }()
cmd/nomos/main.go:117 go func() { ...http server... }()
cmd/nomos/main.go:347 go a.resumeSession(context.Background(), sessionID, note)
internal/mcp/server.go:477,495 go executeApprovedViaAPI(...)
internal/mcp/server.go:1134 go func() { ... }()
internal/httpapi/phase3.go:119,1456
internal/httpapi/server.go:81,533
```
`grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/` returns
nothing. Go's default behavior for a panic in *any* goroutine — not just the
one handling an HTTP request, which the stdlib does recover — is to crash the
**entire process**. `runContinuationWorker` and `resumeSession` in particular
run complex, unattended agent logic (JSON unmarshaling of model output, tool
result parsing, map/slice indexing) with no operator watching; a single edge
case (a malformed tool result, an unexpected nil) takes down nomos for
**every concurrently-running task**, not just the one that hit it. This is
more consequential post-concurrency (today's work): more simultaneous
unattended goroutines running agent code means more surface area for one bad
input to end everyone's session.
**Fix:** wrap every explicitly-spawned goroutine body in a `defer func() {
if r := recover(); r != nil { slog.Error(...) } }()`. A small helper
(`safeGo(func())`) would make this consistent and hard to forget at new call
sites.
### B2. Auto-continuation processes its batch sequentially, one full turn at a time
[continue.go:58-75](../cmd/nomos/continue.go): `processContinuations` fetches
up to 5 pending items and runs `a.continueSession(ctx, p)` for each **in a
plain `for` loop**, in the single `runContinuationWorker` goroutine. Each
`continueSession` is a full LLM turn that can run for minutes (10-minute
timeout, [continue.go:134](../cmd/nomos/continue.go)). If 3 different tasks'
executions finish in the same 4-second tick, task #3's continuation waits for
#1 and #2 to *completely finish* first — undercutting today's whole
concurrency effort specifically on the auto-continuation path, which is the
mechanism autonomous multi-step tasks depend on most.
**Fix:** spawn each pending continuation as its own goroutine (with B1's
panic recovery), bounded by a small semaphore if unbounded parallelism here
is a concern.
### B3. No terminal state for a permanently-failed auto-continuation
[continue.go:162-165](../cmd/nomos/continue.go): if the resumed LLM call
errors on both the initial attempt and its one retry, the code logs an error
and returns — the task is left in whatever status it was in (typically
`executing`), with no outcome set and no operator-visible signal beyond an
inert message buried in the transcript. There's no give-up-after-N-retries or
dead-letter marking; the task just looks silently stuck.
**Fix:** on final failure, call the same path `complete_task` would use to set
`outcome='failure'` with a summary explaining the resume failed, so the task
board reflects reality instead of showing a task that looks perpetually
"executing."
---
## C. Security
### C1. Nomos's own HTTP gateway has zero authentication
[docker-compose.yml:144](../docker-compose.yml) publishes port 8092 directly
(`"8092:8092"`, comment: *"mesh-published"*) and
[Caddyfile.oikos:52-54](../compose/caddy/Caddyfile.oikos) reverse-proxies to
it — as of the client/server split
([2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)), only
from `nomos.hubris.network` now, not two routes: `/agent/*` on
`oikos.hubris.network` was repointed to go through `api`'s own authenticated
proxy mount instead of straight to nomos:8092, but that's `combinedAuth`
authenticating the *hop into api*, not anything nomos itself checks — this
finding is unaffected by that change, still fully open. `grep -n
"Authorization\|Bearer\|auth" cmd/nomos/main.go` still returns **nothing**
for nomos's inbound routes (nomos did gain outbound auth as *part of* the
client/server split — it now sends `Authorization: Bearer
$OIKOS_MCP_BEARER_TOKEN` on its own calls to `api` — but that's the opposite
direction from this finding) — `/chat`, `/sessions`, `/sessions/{id}`
(including `DELETE`), and `/query` have no credential check of any kind.
Anyone who can reach the LAN or mesh network can converse with Nomos
directly: start tasks, read/delete any session, answer pending questions,
and — via chat-assent — approve gated executions by typing "yes" or "I
confirm" to whatever the agent proposes, with no authentication at all. This
is the same class of gap
[oikos-gaps-and-improvements](2026-07-08-oikos-gaps-and-improvements.md)
flagged for the `api`/MCP surface (items B1-B5), but specifically for nomos's
*own* port, which doesn't sit behind `combinedAuth` the way `api`'s routes do.
**Fix:** put nomos's gateway behind the same auth the `api` process uses
(shared bearer token check at minimum), or stop publishing 8092 directly and
route all traffic through the already-authenticated `api` proxy exclusively.
---
## D. Code quality
### D1. Dead code: `isTaskTool` is defined, never called
[tasks.go:139-146](../cmd/nomos/tasks.go). The actual dispatch in
[agent.go:370](../cmd/nomos/agent.go) calls `a.handleTaskTool(...)` directly
and checks its `handled` return value — `isTaskTool` is unused.
**Fix:** delete it, or use it in `buildTools`/dispatch if a cheaper
pre-check is actually wanted.
### D2. N+1 query in `recordTouched`
[store.go:720-742](../cmd/nomos/store.go): loops over every slug found in a
tool call's args and issues a separate `SELECT id, type FROM entities WHERE
slug = $1` per slug. Fine for the common case (1-3 slugs) but doesn't batch
for tool calls naming many entities.
**Fix:** one `SELECT id, slug, type FROM entities WHERE slug = ANY($1)` for
all collected slugs, then loop over the results in memory.
### D3. `complete_task`'s outcome isn't validated
[tasks.go:248-257](../cmd/nomos/tasks.go) declares an `enum` in the tool
schema (`success|failure|partial`) but [store.go:428-457](../cmd/nomos/store.go)
never checks it — an out-of-enum value (a model typo, or a weaker model not
respecting the schema) silently persists as-is; only `"failure"` is
special-cased (else `status="done"`), so a stray value still "completes" the
task but with a value the frontend's status/outcome rendering doesn't
recognize.
**Fix:** validate against the three allowed values in `handleTaskTool` before
calling `store.completeTask`, defaulting unrecognized values to `"partial"`
(safer than silently treating them as `"success"`).
---
## E. Test coverage
**Zero automated tests exist for `agent.go`, `store.go`, `main.go`, or
`tasks.go`.** Only `assent.go`'s and `continue.go`'s pure string-parsing
helpers have unit tests (`assent_test.go`, `continue_test.go`) — confirmed by
`grep -l "func Test" cmd/nomos/*.go` matching only those two files. This means
today's session added substantial new, safety-critical logic — session-scoped
assent/destructive windows, the `mcpClientPool`'s creation-race handling and
eviction sweep, `proposePlan`'s replace-vs-append branching — verified only by
live manual testing (curl + browser), with **no regression protection**
against a future change silently reintroducing the cross-task assent bleed or
breaking the pool's session isolation.
**Fix (highest-value additions first):**
1. `store_test.go`: `proposePlan`'s append-vs-replace branch (the exact bug
fixed earlier today) — needs a real DB (integration-style, matching
`internal/db/integration_test.go`'s pattern) or a query-mocking layer.
2. `main_test.go`: `mcpClientPool.get()`'s concurrent-creation race path (two
goroutines racing to create a client for the same new session id) and
`sweep()`'s eviction logic — these are pure in-memory logic, no DB needed,
straightforward to unit test.
3. `assent_test.go`: the two confirmed false-positive cases from A1.
---
## F. Efficiency (minor)
### F1. Tool list + fleet snapshot re-fetched every single turn
[agent.go:174,181](../cmd/nomos/agent.go): `buildTools` (`tools/list` MCP
round-trip) and `fleetSnapshot` (`get_health_summary` call) both run at the
start of **every** `chatWith` call — including auto-continuation resumes,
which can fire many times per task. The tool list changes only on an `api`
process restart; the fleet snapshot is a live "as of now" read, which is
arguably the point of it, but re-fetching the *tool list* every turn is
avoidable.
**Fix:** cache `buildTools`' result (e.g., in `mcpClientPool`, invalidated on
a client's re-initialize) — worth doing only if profiling shows it matters;
low priority relative to A-C.
---
## Implementation order
1. **A1** (assent false positives) — smallest, highest-severity-per-line-of-
code fix; ships with regression tests same-PR.
2. **C1** (unauthenticated gateway) — security-critical, independent of
everything else here.
3. **B1** (panic recovery) — cheap, broad safety net; do before B2 touches the
continuation worker's goroutine structure anyway.
4. **B2** (parallel auto-continuation) — natural follow-on to B1 since it's
restructuring the same goroutine.
5. **A3** (incremental persistence for live turns) — moderate effort, real
user-visible correctness gain.
6. **D1-D3** (small cleanups) — bundle together, low risk.
7. **A2** (history windowing) — needs a design decision (see below) before
implementation; largest single change.
8. **B3**, **F1** — lower urgency, do opportunistically.
9. **E** (tests) — ideally lands alongside each fix above (A1's tests with
A1, etc.) rather than as one giant deferred test-writing pass.
## Verification
- **A1**: the two probe cases (`isAssent` on the "yesterday" message,
`isTypedConfirmation` on the "haven't confirmed" message) become permanent
tests in `assent_test.go`, asserting `false` post-fix.
- **A2**: after adding windowing, replay a session with 70+ tool calls (the
documented production case) and confirm the message payload sent to the LLM
stays under a fixed token/byte ceiling regardless of session length.
- **A3**: reproduce the Stop-button-mid-turn scenario, reload the page, and
confirm the tool calls made before the abort are still present in the
persisted transcript (currently: they vanish).
- **B1**: inject a deliberate panic in a test build of `resumeSession` (or a
fault-injection flag), confirm the process survives and logs the recovered
panic instead of exiting.
- **C1**: confirm an unauthenticated `curl` to nomos's `/chat` from off-mesh
is rejected once auth lands (currently: succeeds).
- **D1-D3**: `go vet`/build clean, `complete_task` with a bogus outcome value
now rejected or defaulted rather than silently persisted.
## Open questions
- **A2's cutoff mechanism**: a fixed N-message window, a token-budget-aware
trim, or LLM-summarization of dropped history? Summarization preserves the
most context but costs an extra LLM call per trim; a fixed window is
simplest but could drop something the agent still needs mid-task. Leaning
fixed window + summarize-on-trim as a middle ground, but this needs a
decision before implementation, not during.
- **C1's auth mechanism**: reuse `api`'s existing static bearer token
(simplest, matches an existing pattern) or route everything through `api`'s
proxy and stop publishing 8092 at all (removes the surface entirely, but
changes the deploy topology)? Leaning the latter if nothing else on the LAN
legitimately needs to reach nomos directly — worth confirming with the
operator before picking.
- **B2's concurrency bound**: unbounded goroutines-per-tick vs. a small
semaphore? Given the continuation batch is already capped at 5 per tick
(`pendingContinuations(ctx, 5)`), unbounded is probably fine, but worth a
sanity check against real task-completion clustering patterns.

View File

@@ -0,0 +1,159 @@
# 2026-07-14 — Session review + Activity timeline refinements
**Status:** Planned
## Session analysis: `722d8878` (2026-07-14T10:45)
"Fleet-wide audit: check all services, identify what needs updating"
### What happened
- User asked for fleet audit → agent called `set_goal` (session → executing)
- 82 tool calls in ONE turn: 58 `run`, 8 `get_entity`, 4 `get_relations`, etc.
- **No `propose_plan`** — no plan steps, entered executing directly
- **No `complete_task`** — agent produced a final text response but never closed the task
- Session stuck at `executing` with a text conclusion but no terminal state
### Gaps found
| # | Issue | Root cause |
|---|---|---|
| 1 | "Agent is thinking" stuck at end | `liveStatus === 'executing'` is true even after the turn ends. AgentIndicator shows generic message because there are no running activity entries to describe. |
| 2 | No plan proposed | Model skipped `propose_plan` — possibly because it found prior knowledge and skipped to execution. `setGoal` now sets `executing` directly (our fix), which makes `propose_plan` optional — but the sidebar "Plan" section shows "No plan yet" permanently. |
| 3 | Task never completed | Model produced final text but never called `complete_task`. The idle sweep (2 min now) will nudge, then auto-close. |
| 4 | Approvals invisible in Activity | 58 `run` calls, many requiring approval. These show in chat via InlineApproval but NOT in the Activity timeline. The operator has to switch to Ops page to track approvals. |
| 5 | Activity is newest-first | Timeline shows newest at top. Feels unnatural for a sequential log — bottom-scrolling with newest at bottom is more intuitive for "watching" what the agent does. |
| 6 | Knowledge recorded not shown | The agent recorded knowledge but it doesn't appear in Activity if it came from prior knowledge entries via `get_knowledge_content`. |
---
## Plan
### 1. Fix "Agent is thinking" stuck indicator
**Root cause:** AgentIndicator shows when `active={$streaming || liveStatus === 'executing'}`. After the turn completes, `liveStatus` is still `'executing'` and there are no running entries, so the label falls through to the generic `"Agent is thinking…"` fallback.
**Fix:** Change the active condition to only show when there's actual work:
```ts
active={$streaming || $activityLog.some((e) => e.status === 'running')}
```
This way it shows during streaming AND when there are running tools (auto-continuation), but NOT when the session is just "executing" with no active work.
Also: when the last assistant message has text AND no pending tool calls, auto-hide the indicator. The `liveStatus === 'executing'` check is too broad — it covers the entire session lifetime.
### 2. Approvals in Activity timeline
**What:** InlineApproval cards show in chat but not in Activity. Every `run` that queues an execution with "requires approval" should appear as an entry in the Activity timeline.
**How:**
- In `activity.ts`, detect tool results containing "requires approval" + execution ID
- Add `type: 'approval_pending'` entries with the execution ID, target, action, and status
- Poll execution status and update the entry (pending → approved → running → completed/failed)
- The InlineApproval component stays in chat for the Approve/Deny buttons
- Activity shows the full lifecycle: approval requested → approved → running → done
### 3. Old-to-new ordering
**Fix:** Remove `.sort((a, b) => b.timestamp - a.timestamp)` → change to `.sort((a, b) => a.timestamp - b.timestamp)` or no sort at all (entries are already added in chronological order).
This means the timeline reads top-to-bottom as the session unfolds. Currently `newest at top` means the "Goal" and "Plan" entries appear at the bottom, which is confusing.
### 4. Knowledge detection
**Fix:** Extend the knowledge detection in `activity.ts` to also catch `upsert_knowledge` calls from `tool_use` events (not just `tool_result`), so the entry appears as "running" while recording and then "done" when the result comes back.
### 5. Auto-hide indicator when turn ends with text
**Fix:** Detect when the last assistant message has text content AND there are no pending tool_use entries without matching tool_result. In that case, the turn is complete — don't show the indicator.
---
## Plan-approve-once (new policy)
### Problem
Today: agent calls `propose_plan` + `run` × 10 in the same turn. Each `run`
queues an individual approval. Operator sees 10 "requires approval" cards.
After operator types "yes", each one is individually approved, THEN the
assent window opens and future calls auto-run.
The operator shouldn't see per-action approvals when they already approved
the plan. The plan IS the approval. Individual actions within an approved
plan should auto-execute.
### Target
```
User: "audit fleet"
Agent: "Here's my plan: 1. List LXCs 2. Check apt on each 3. Report" ← proposes plan
[Proposed plan: 3 steps] [Approve plan?]
User: "approved"
Agent: ◉ Listing containers… ← auto-runs
◉ Checking apt on lxc:jellyfin… ← auto-runs
...
"Done. 19 LXCs have pending updates."
```
One approval for the plan. All actions within it auto-execute. No per-action
approval cards. Only re-approve when the agent calls `propose_plan` again
(significant plan change).
### How (server-side)
The classification logic in `internal/mcp/server.go:run()` needs to know whether
a plan-approval-assent-window is active for this session. Currently it checks
`autonomy_settings` for the assent window key. The change: when `propose_plan`
is called, pre-activate the window with a "plan-proposed" state. When the
operator approves, transition to "plan-active". `run` calls within an active
plan window auto-execute at `config_mutation` level.
Key change in `store.go:proposePlan()`:
```go
// Pre-record a plan-proposed window so that run calls know a plan is pending approval.
// Once approved, this becomes the full assent window.
key := planWindowKey(agentID, sessionID)
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, 'proposed')
ON CONFLICT (key) DO UPDATE SET value = 'proposed'`, key)
```
Then in the `run` handler, check for `plan-active` OR `assent-active`:
```go
// If a plan window is active, this run call is part of the approved plan
// and config_mutation commands auto-execute without individual approval.
if planWindowActive(ctx, agentID, sessionID) { ... }
```
### How (frontend)
- Instead of 10 individual InlineApproval cards, show ONE "Approve plan?" card
- When approved, all queued `run` calls from the plan turn auto-grant
- Activity timeline shows plan approval as one entry: "✓ Plan approved — 12 actions"
- Subsequent `run` calls show with an "auto (plan)" badge instead of approval cards
---
## Implementation plan (consolidated)
### Phase A — Quick fixes (today)
| # | Fix |
|---|---|
| A1 | AgentIndicator: only show when `$streaming || hasRunningActivity` (not on `liveStatus === 'executing'`) |
| A2 | Activity timeline: old-to-new ordering |
| A3 | Knowledge entries: detect `tool_use` events for running state |
### Phase B — Approvals in Activity
| # | Fix |
|---|---|
| B1 | `activityLog`: add `approval_pending` / `approval_granted` / `execution_running` / `execution_done` lifecycle entries |
| B2 | Poll execution status and update Activity entries inline |
| B3 | InlineApproval stays in chat (needs operator action), but lifecycle tracked in Activity |
### Phase C — Plan-approve-once (policy change)
| # | Fix |
|---|---|
| C1 | Backend: plan window in `autonomy_settings`, checked by `run` handler |
| C2 | Frontend: single "Approve plan?" card instead of per-action cards |
| C3 | Backend: when plan is approved, auto-grant all pending executions from the plan turn |
| C4 | Activity: plan approval as single timeline entry with action count badge |

View File

@@ -0,0 +1,133 @@
# 2026-07-14 — Unified sidebar activity timeline
**Status:** Planned
## Current state (broken)
The sidebar has three sections that appear/disappear independently:
| Section | When visible | Shows |
|---|---|---|
| Plan (top) | When `$planSteps.length > 0` | Plan step names with progress bar |
| Tool activity (middle) | When `toolCount > 0` | Compact tool list, grouped by turn |
| This session (bottom) | When `digest.total_executions > 0` | Post-hoc execution count + knowledge |
State changes cause sections to **pop in/out** as the agent moves between
planning → executing → done. The "0 tools · 0 running" counter flashes
briefly then vanishes. Tool activity appears/disappears between turns.
## Target: single unified timeline
One section, always present when a session is loaded. Every agent action
appears as an entry in reverse-chronological order (newest at top).
```
┌─ Activity ───────────────────────── ─┐
│ │
│ ✓ Task completed: "Upgraded 4 LXCs" │ ← newest
│ ◉ Running: apt upgrade on lxc:dns │
│ ✓ run: apt upgrade on lxc:gitea │ ← tool completed
│ ✓ Verified gitea: HTTP 200 │
│ ◉ Step 3/5 — Upgrade dns │ ← plan step running
│ ✓ Step 2/5 — Upgrade gitea │ ← plan step done
│ ✓ run: apt upgrade on lxc:nfs-export │
│ ◉ Step 1/5 — Upgrade nfs-export │
│ ✓ Knowledge recorded │
│ 📋 Plan set: 5 steps │ ← plan proposed
│ 🎯 Goal: Upgrade 4 low-risk LXCs │ ← goal set
│ │ ← oldest
└───────────────────────────────────────┘
```
### Entry types
| Type | Icon | Example description |
|---|---|---|
| `goal` | 🎯 | "Audit all LXCs for updates" |
| `plan` | 📋 | "Plan set: 5 steps" |
| `step_start` | ◉ spinner | "Step 2/5 — Upgrade gitea" |
| `step_done` | ✓ | "Step 2/5 — Upgrade gitea" |
| `tool_start` | ◉ spinner | "run: Upgrade nfs-export (21 pkgs)" |
| `tool_done` | ✓ | "run: 0 upgraded, 0 newly installed" |
| `tool_error` | ✗ | "run: SSH handshake failed" |
| `knowledge` | ✨ | "Recorded: How to run fleet upgrades" |
| `complete` | ✓ | "Task completed: success" |
| `question` | ❓ | "Asked: Which host for the LXC?" |
| `error` | ✗ | "Auto-resume failed: context deadline exceeded" |
### Data source
Entries come from all available sources, merged and deduplicated:
1. **`toolTimeline` store** (live tool_use/tool_result pairs)
2. **`planSteps` store** (step status transitions)
3. **Session digest API** (knowledge created, final outcome)
4. **`currentTask` store** (goal, status)
Deduplication: when a plan step links to a tool call via `execution_id`, show
them as one entry instead of two (e.g. "Step 3: Upgrade dns ◉ running" includes
the tool — don't show a separate "run: apt upgrade" entry).
### Behavior
- **Always visible** when `$currentSession` is set
- **Reverse chronological** — newest entries at top, scrolls naturally
- **Auto-expands** the entry for the currently-running tool/step
- **Collapses** completed entries to one line (expandable)
- **Polls** every 3s for live updates (same as current startPolling)
- **No flashing** — entries only change status in place (tool_start → tool_done), never removed
- **Persists** across page navigation (rehydrated from REST on load)
- **Empty state** when no session: "Open a session to see agent activity"
### What gets removed from chat
- **ToolCallGroup** — the compact tool counter. Tools live in the timeline now.
- **AgentIndicator at bottom** — partially. Keep it ONLY for the initial
"thinking" state (before any tools fire). Once the first tool fires, the
timeline is the source of truth and the chat indicator is redundant.
Actually: remove it entirely. The timeline IS the indicator.
### What stays in chat
- **Agent text responses** — the thinking, conclusions, reports
- **InlineApproval cards** — approvals need operator action, must be in chat
- **Inline tool renderers** — entity cards, health summary, etc. (informational)
- **User messages** — obviously
## Implementation
### 1. Data layer: `activityLog` derived store
Add to `chat.ts`:
```ts
export interface ActivityEntry {
id: string
type: 'goal' | 'plan' | 'step_start' | 'step_done' | 'step_failed' |
'tool_start' | 'tool_done' | 'tool_error' |
'knowledge' | 'complete' | 'question' | 'error'
description: string
detail?: string // tool result text, step detail, etc.
timestamp: number // Date.now() when created
seq?: number // plan step seq, for ordering
toolName?: string // for tool entries
status: 'running' | 'done' | 'failed'
collapsed: boolean // initial collapsed state (true for completed)
}
```
Derived reactively from `messages`, `planSteps`, `currentTask`, and session
digest data. Uses `$derived.by()` to recompute when any source changes.
### 2. New component: `ActivityTimeline.svelte`
Replaces all three sidebar sections. Renders `activityLog` entries as a
vertical timeline with connecting lines.
### 3. Remove from chat
- `<ToolCallGroup>` rendered in chat
- `<AgentIndicator>` at bottom
### 4. Update TaskContextPanel
Replace PlanProgress + SessionDigest with ActivityTimeline.

View File

@@ -0,0 +1,889 @@
# 2026-07-14 — Post-fix session audit: empty responses & plan drift remainders
**Status:** Done — 2026-07-14. All 18 fixes shipped, e2e-validated via the
golden eval harness (4/4 passed), committed (`337d577` + `3de359b` +
`dd3076a`), pushed to `main`, and deployed to `oikos-nomos-1` (v0.5.3). The
knowledge loop is structurally closed, the plan-duplication chain is broken,
and the eval harness catches regressions on future changes.
**PM addition — OIDC token-refresh fix** (lines 9-23 below) also shipped:
committed as `3b98097` ("fix(web): refresh expired OIDC tokens before API
calls"). The root cause of the empty-graph symptom is fixed and deployed.
**2026-07-14 (PM) — OIDC token-refresh fix (unplanned, root-cause for the
empty graph symptom):** the overview background graph and the Knowledge Base
graph both rendered empty because the SPA's OIDC access token expired
(~5 min TTL) and was never refreshed. `fetchWithAuth` called `getToken()`
synchronously (no refresh); `ensureToken` returned the stale token without
refreshing; `storeTokens` discarded `expires_in`; and the resulting 401
made `fetchGraph` return `null` → both graphs drew nothing, with no error
surfaced. Fixed structurally in `web/src/lib/oidc.ts` +
`web/src/lib/config.ts` + `web/src/lib/stores/events.ts`: tokens now carry
`expiresAt`, `getToken()` returns null within 30s of expiry, `fetchWithAuth`
awaits `ensureToken()` (refreshes on demand), `sseUrl` is async + refreshes
before constructing the EventSource, and a 401 flushes the OIDC session so
the static token fallback takes over. Build passes. Not yet committed or
deployed (pending operator verification). Not part of any numbered phase
above — filed here because it was the highest-impact surface symptom.
## Shipped (2026-07-14, v0.5.0v0.5.3 — commits 337d577 + 3de359b + dd3076a, deployed)
| Fix | File(s) | Validation |
|---|---|---|
| **A.1** `proposePlan` sets `generation` on INSERT | `cmd/nomos/store.go` | eval: plan steps carry `generation: 1` |
| **A.2** `proposePlan` refuses re-proposal when in flight (drops append-mode) | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `propose_plan` called exactly once on "proceed" |
| **A.3** `propose_plan` tool description restated as a crisp contract | `cmd/nomos/tasks.go` | agent self-described the contract |
| **F.3** Approval vocabulary expanded + directive result strings | `cmd/nomos/tasks.go` | eval: "proceed" and "go ahead" both recognized as approval |
| **B.1** `chatWith` emits `done` after `error` on every terminal path | `cmd/nomos/agent.go` | eval: no reconnect/resume entries in nomos logs |
| **B.2** Reconnect/resume note carries last user msg + plan-in-flight directive | `cmd/nomos/store.go`, `cmd/nomos/main.go`, `cmd/nomos/continue.go` | wired into all 4 resume entry points |
| **B.3** `resumeSession` escalates the recovery note across 3 attempts | `cmd/nomos/continue.go` | e2e: escalated retry produced a real response |
| **D.1** `complete_task` refused when discovery ran without writeback | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | e2e: agent REFUSED → wrote back → retried → succeeded |
| **D.2** `propose_plan` auto-appends a writeback step if missing | `cmd/nomos/tasks.go` | e2e: appended step 4 when agent omitted writeback |
| **F.1** Consolidated SOUL.md's three overlapping task-flow sections to one | `nomos/SOUL.md` | eval: agent follows the consolidated flow (4/4 evals pass) |
| **F.2** Tightened set_goal/update_plan_step result strings to imperatives | `cmd/nomos/tasks.go` | eval: tool results are now directive |
| **C.1** `completeTask` rejects re-completion of a terminal session | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `complete_task` called exactly once |
| **C.2** SOUL.md: don't re-execute on UI-clarification complaints | `nomos/SOUL.md` | eval: no re-execution on followup |
| **B.4** Surface real model error text (finish_reason + refusal) | `cmd/nomos/agent.go` | error event now carries `finish_reason=length` etc. |
| **B.5** Back off between resume retries (4s, 8s) | `cmd/nomos/continue.go` | exponential backoff between attempts |
| **B.6** Don't persist empty placeholder as a visible bubble | `cmd/nomos/main.go`, `cmd/nomos/store.go` | empty rows deleted, not persisted |
| **E.1** SOUL.md: prefer knowledge over re-execution for fleet-wide facts | `nomos/SOUL.md` | eval: `search_knowledge` called first, 0 `run` calls on fleet audit |
| **E.2** `list_lxcs` last-audited hint in the result | `internal/mcp/server.go` | `last_audited_at` column via `about` edge subquery |
| **Bonus** Fixed pre-existing tool-call doubling bug in persistence | `cmd/nomos/main.go`, `cmd/nomos/continue.go` | eval: tool-call counts now accurate (was 2× in every session since v0.3.x) |
Tests: `TestProposePlan_RefuseInFlight` + `TestHadDiscoveryAndWriteback` in
`cmd/nomos/store_test.go`. Golden eval harness: `cmd/nomos/eval/` with 4
conversations in `cmd/nomos/eval/evals/golden.yaml` — all 4 pass.
## Golden eval results (v0.5.3, 4/4 passed)
| Eval | Tool calls | Key assertions |
|---|---|---|
| trivial_readonly | 2 | no plan, no run, completes |
| plan_advances_on_proceed | 13 | propose_plan ×1, writes back, complete_task ×1 |
| ui_complaint_no_rerun | 12 | propose_plan ×1, writes back |
| knowledge_preferred_over_rerun | 7 | search_knowledge ×1, 0 run calls |
Run: `go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest cmd/nomos/eval/evals/*.yaml` (~$0.10/run).
## Remaining (not yet shipped)
None. All 18 fixes + the OIDC token-refresh fix (PM addition, `3b98097`)
are shipped, committed, and deployed.
## Commit-history context (the 20-commit iteration)
Reviewing `git log` since the agent-task phases landed (be3ce76 → 5caf49b),
the same problems recur because we keep fixing them with **SOUL.md prose +
safety-net append logic** instead of structural gates:
- `5384499` (Jul 11) — "plan panel showed only the latest step" → fixed by
making `proposePlan` APPEND when a step is in flight, so history is
preserved even if the model re-proposes per step. **This is the source of
the duplication the operator saw today.** The fix traded "lost progress"
for "duplicate progress" — and the duplication is what's visible to the
operator now.
- `e30813a` / `532310b` (Jul 11) — "research-first / knowledge-write-back-
last explicit steps" → added the FIRST/LAST step language to SOUL.md.
Three commits later the warnings are still being ignored in production.
- `5caf49b` (Jul 14, today) — "mandatory pre-plan flow" → another SOUL.md
section at the top of the file, overlapping the existing "Every chat is a
task" / "AFTER EVERY TASK: WRITE BACK" sections. The agent now has three
overlapping sections telling it the same thing.
- `60effcb` (Jul 14) — Phase 5 of the prior plan added the `generation`
column, the `replaced` status, the frontend grouping, and the writeback
warnings. The migration landed; the INSERT in `proposePlan` did not.
**The pattern:** every iteration adds another paragraph to SOUL.md and a
safety net in the store layer. The agent still does the wrong thing
because prose instructions are unreliable and the safety nets paper over
the symptom instead of refusing the bad action. **This plan pivots to
structural gates** — `proposePlan` and `completeTask` should refuse the
calls that produce drift, not accommodate them.
## Sessions under audit
| Session | Time | Goal | Messages | Outcome | Real tool calls |
|---|---|---|---|---|---|
| `722d8878` (failure) | 10:45 | Fleet update audit | 3 | **failed** — empty response during auto-resume | 41 in turn 1 |
| `d9cdcee1` (success w/ friction) | 11:44 | Same prompt (user retried) | 11 | success | 38 across 5 turns |
Both sessions are the same operator request: "Check all the services on the
homelab and give me an overview of what needs updating, categorize by
criticality." Cross-referencing them shows **where the prior fixes held vs.
where they didn't.**
---
## What worked (preserve)
- **`upsert_knowledge` `about` array** (5.2 from prior plan) — the agent
linked the audit to all affected LXCs in one call:
`about: ["lxc:nextcloud","lxc:jellyfin","host:hubris", ...]`.
- **`complete_task` writeback warning** (5.5) — fired correctly (the session
has no `update_entity_attributes` calls and the warning text appears in the
tool result).
- **`propose_plan` writeback nudge** (5.4) — fired (last step title was
"Write back: upsert_knowledge if anything changed", which contains neither
required tool name).
- **Seq-order completion enforcement** (5.6) — no out-of-order completions
observed.
- **Replaced-status mechanism** (3.3) — pending steps from the prior
generation were correctly marked `replaced` on re-propose.
## What didn't (the findings below)
---
## Findings
### 1. Empty response still ends the session — operator had to start over
**Where:** Session `722d8878` msg 2: `[System: auto-resume failed after
retrying: Nomos returned an empty or unusable response — please retry. The
task is paused — send another message to continue.]`
**What happened:** Turn 1 ran 41 tool calls (set_goal + list_lxcs +
get_health_summary + get_state_snapshot + search_knowledge + 4× get_relations
+ 4× get_entity + 20× `run` for `apt-get update` across the fleet). The model
returned that successfully. Auto-continuation then ran `resumeSession`, which
retried `chatWith` **3 times** (continue.go:229) — all three came back empty.
The session ended with the system note above. The operator abandoned it and
opened `d9cdcee1` with the same prompt.
**Root cause:** Three identical retries with the same injected `note` produce
three identical empty responses (the model isn't randomly failing — it's
responding to the prompt the same way each time). The retry loop never varies
the prompt, never backs off, and never escalates to a more aggressive
recovery (e.g. a fresh continuation prompt that summarizes what just happened
and asks explicitly for the next single step).
**Severity:** Blocker — a 41-tool-call turn costs real money and time, and the
operator gets nothing for it.
### 2. `generation` column exists but `proposePlan` never sets it — frontend grouping is dead code
**Where:** `cmd/nomos/store.go:458-461` (INSERT statement) vs.
`migrations/020_session_reliability.up.sql:7` (the column) and
`web/src/lib/components/PlanProgress.svelte:17-22` (the grouping logic).
**What happened:** Migration 020 added `generation INTEGER NOT NULL DEFAULT 1`
and PlanProgress groups steps by `s.generation ?? 1`. But the INSERT in
`proposePlan` is:
```sql
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
VALUES ($1, $2, $3, $4, $5) RETURNING id
```
No `generation` column. Every step, in every plan revision, lands with
`generation = 1`. PlanProgress always sees one group ("Current plan") and
the collapse-old-generations behavior never triggers.
**Concrete impact in `d9cdcee1`:**
- Turn 1 (msg 1): `propose_plan` creates steps seq 1-5 (all generation 1).
- User: "why is the plan not updated accordingly? the steps in the sidebar."
- Turn 3 (msg 5): `update_plan_step seq=1, status=done`. Steps 2-5 still
pending, all generation 1.
- User: "proceed with the rest."
- Turn 4 (msg 7): **empty assistant response** (text="", no tools).
- Turn 5 (msg 8): Agent calls `propose_plan` **again** with the same 5 steps.
`proposePlan` sees `anyStarted=true` (seq 1 is done), so it goes into
append mode: marks the 4 still-pending steps (2-5) as `replaced`, then
inserts 5 new steps at seq 6-10. **All inserted with generation=1.**
- The frontend now sees 10 steps, all `generation: 1`, grouped together.
Four are marked `replaced` (visible as "skipped/replaced" — dimmed but
still in the list); six are the new active steps.
- User: "btw the plan here and the one in the sidebar differ." → Confirmed:
the chat text describes a 5-step plan ("Step 1 done, refreshing 2-4");
the sidebar shows 10 steps with a confusing mix of done/replaced/running.
**Severity:** Blocker — this is the direct, observable cause of the user's
two complaints in `d9cdcee1`. The prior plan (3.4) shipped the column and
the frontend code but never wired the backend INSERT.
### 3. Agent re-proposes the plan on "proceed" instead of continuing
**Where:** `d9cdcee1` msg 8 — `propose_plan` called again after user said
"proceed with the rest".
**What happened:** The agent had a perfectly good plan in flight (step 1 done,
2-5 pending). On the next operator turn ("proceed"), it should have called
`update_plan_step(seq=2, status=running)` and `run` against the targets.
Instead it called `propose_plan` with the same 5 steps, triggering the
append-mode behavior in #2.
**Root cause:** SOUL.md doesn't explicitly say "do NOT call propose_plan
again once you've already proposed — call update_plan_step + run instead."
The agent treated "proceed" as a cue to re-state the plan, not to advance
it.
**Severity:** Friction (compounds #2 into a blocker).
### 3a. WHY the agent re-proposed instead of advancing — the three-bug chain
Finding #3's surface description ("agent re-proposed on proceed") is real but
doesn't explain the *mechanism*. Tracing the message timestamps and the
`auto: true` flag on msg 8 reveals that the re-proposal wasn't the agent's
direct response to "proceed with the rest" at all — it was the agent's
response to a **generic system reconnect note**, fired by a chain of three
compounding bugs:
**The chain (all confirmed from code + session data):**
| Step | What happened | Where |
|---|---|---|
| 1. **Trigger** — model returned empty on the approval | User sent "procceed with the rest." `handleChat``a.chat()``chatWith()`. The model returned an empty completion 3× (all `maxLLMRetries=2` attempts exhausted). SOUL.md's approval vocabulary was "approved/yes/go ahead" — "proceed" wasn't listed, so the model likely wasn't certain it was approved and no-op'd. | `agent.go:362-371` |
| 2. **Amplifier** — empty response misclassified as network disconnect | On empty response, `chatWith` emits `error` and `return`s **without emitting `done`** (agent.go:370-371 — the `done` event only fires on the success path at line 382). The frontend's `onComplete` callback sees `!receivedDone` and treats it as a severed connection, calling `handleDisconnect()`. A *model* empty-response gets handled by the *network* disconnect path. | `agent.go:370-371` (missing `done`) + `chat.ts:349-356` (`!receivedDone → handleDisconnect`) |
| 3. **Divergence** — generic reconnect note triggers re-proposal | `handleDisconnect` waits 1s, then sends an empty message (`streamChat('', sessionId, …)`). The backend's reconnect path (main.go:177-188) calls `resumeSession` with: `"[System: the operator's connection was re-established. The task may have progressed in the background — report your current state and progress.]"`. The agent re-read the transcript (plan proposed, step 1 done, user said "proceed"), saw this generic note, and interpreted "report your current state and progress" as "redo the work and report it" → re-proposed + re-executed + `complete_task`. | `chat.ts:386-419` (reconnect) + `main.go:180` (note) + `continue.go:189` (resumeSession) |
**Timestamps confirm this:** msg 7 (empty) at `11:49:01.949`, msg 8 (re-propose, `auto: true`) at `11:49:16.200` — 15 seconds later, matching the 1s reconnect delay + the LLM call latency. The user never sent a second message; the frontend's reconnect logic did.
**The user's actual approval ("procceed with the rest") was in the transcript** but the agent wasn't responding to it — it was responding to the *system reconnect note*, which didn't mention approval, the plan, or the user's words. The propose_plan result had said "STOP and wait for approval," and the generic reconnect note didn't say "you're approved" — so the agent re-proposed to get a fresh approval cycle.
**Why this matters for the fix:** Phase A.2 (refuse re-proposal when in flight) would have *prevented the duplication* but not *fixed the cause*. The agent would have hit the refusal and then… what? With the generic reconnect note, it still doesn't know it's approved. The three bugs need three targeted fixes (Phase B below). This is the answer to "why didn't the agent update the original plan": **it never received a clear signal to advance, because the approval signal was lost in an empty response that got misclassified as a network drop.**
**Severity:** Blocker — this is the root cause of the plan divergence the
operator observed.
### 4. Operator clarification was interpreted as "redo the whole task"
**Where:** `d9cdcee1` msg 9 → msg 10. User said "btw the plan here and the
one in the sidebar differ." Agent's response (msg 10): re-ran all 6 `run`
calls (`apt-get update` + `apt list --upgradable` on nextcloud, jellyfin,
hubris), re-called `upsert_knowledge`, and **called `complete_task` a
second time**.
**What happened:** The operator wanted the sidebar aligned with the chat.
The agent re-executed the actual audit work and re-completed the task.
**Root cause:** No prompt-level instruction about how to handle "the UI
seems inconsistent" complaints — the agent defaulted to "do the work again,
maybe it'll line up this time."
**Severity:** Friction — wasted 6 `run` calls and a duplicate knowledge
entry; user gets a noisier transcript.
### 5. `complete_task` called twice on the same session
**Where:** `d9cdcee1` msg 8 and msg 10 both call `complete_task` with
`outcome=success`.
**What happened:** After msg 8, `agent_sessions.status` is `done`. The user
complained about the plan drift; the agent re-ran the audit and called
`complete_task` again. There's no guard in `completeTask` against re-completing
an already-terminal session.
**Severity:** Cosmetic, but it produces duplicate knowledge entries and
erodes audit-log clarity.
### 6. Turn 1 of `722d8878`: 41 tool calls including `run` against every LXC
**Where:** Session `722d8878` msg 1.
**What happened:** Despite a same-day knowledge entry
(`investigation:nomos/fleet-wide-apt-update-audit-2026-07-14` — the agent even
called `get_knowledge_content` for it), the agent ran `apt-get update` on
every LXC in turn 1 instead of presenting the prior audit and proposing a
small refresh plan. The agent already had the answer in the DB; it re-ran
the fleet audit anyway.
**Severity:** Friction — wasted ~20 `run` calls (each is a queued execution).
The successful retry session (`d9cdcee1`) only re-ran 3 (the critical trio),
which is the right pattern — but it had to learn that from the failure
session's example.
### 7. Agent ignores its own writeback warnings
**Where:** `d9cdcee1``propose_plan` returned the nudge from tasks.go:213
("⚠️ The final step doesn't mention update_entity_attributes…") and
`complete_task` returned the warning from tasks.go:280 ("⚠️ No entity
attributes or relationships were updated in this session…"). The agent saw
both, did nothing about either, and ended the task.
**What happened:** The warnings are surfaced in the tool result text, but
the model treats tool results as ephemeral context — it doesn't act on a
warning that appears after the work it already decided is done. The session
recorded zero `update_entity_attributes` calls and zero
`create_relationship` calls.
**Severity:** Blocker — the knowledge-loop drift problem the prior plan was
supposed to fix is still happening. The graph accumulates nothing structured
from this session; the next fleet audit will rediscover every fact from
scratch.
### 8. Empty assistant bubble persisted in the transcript
**Where:** `d9cdcee1` msg 7: `{"role":"assistant","text":"","tool_calls":[]}`.
**What happened:** On the "proceed with the rest" turn, the model returned an
empty completion. The inner `chatWith` retry (agent.go:331) eventually
succeeded and produced msg 8 — but the empty msg 7 was already persisted to
the transcript and stays there. The UI shows an empty assistant bubble between
the user's "proceed" and the agent's actual response.
**Severity:** Cosmetic, but visible to the operator and erodes trust ("is
the agent broken?").
---
## Improvement plan
### Phase A — Make `propose_plan` refuse duplication (addresses #2, #3)
The operator's "plan was added twice" complaint is the visible output of
the append-mode safety net added in `5384499`. The safety net was the wrong
default: it preserved history but produced a confusing 10-step sidebar. The
right default is to **refuse** a re-proposal when a plan is already in
flight — the agent must use `update_plan_step` + `run` to advance.
#### A.1 — `proposePlan`: set `generation` on insert (still needed for history)
**File:** `cmd/nomos/store.go:415-491`
**How:**
1. Resolve the next generation number at the top of `proposePlan`, in the
same transaction:
```go
var nextGen int
if !anyStarted {
// fresh/revise: reset to 1 (and the DELETE already wiped old rows)
nextGen = 1
} else {
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(generation), 0) + 1
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
return nil, err
}
}
```
2. Add `generation` to the INSERT:
```sql
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
```
Pass `nextGen` as `$6`.
3. Include `"generation": nextGen` in the `out` map so the tool result and
the `plan.proposed` event carry it (the frontend already reads it via
`api.ts:66`).
4. Backfill is unnecessary — existing rows default to generation 1.
#### A.2 — `proposePlan`: refuse re-proposal once any step has started
**File:** `cmd/nomos/store.go:415-491` + `cmd/nomos/tasks.go:206-219`
**How:**
1. In `proposePlan`, when `anyStarted == true`, return a sentinel error
instead of appending:
```go
if anyStarted {
return nil, errPlanInFlight
}
```
2. In `handleTaskTool`'s `propose_plan` case, detect the sentinel and return
a directive tool result:
```
Plan already in flight — refusing duplicate proposal. Steps 1..N exist;
at least one is running or done. To advance the plan, call
update_plan_step(seq=K, status=running) followed by run(...) for step K's
target. Do NOT call propose_plan again. Call it again only if the
operator explicitly asks you to revise the whole plan, and if so, say
that in your reply before calling it.
```
3. Drop the append-mode code path (store.go:437-448) — it's the duplication
source. Keep the destructive-replace path (store.go:432-436) for the
`!anyStarted` case (genuine pre-execution revision).
4. The `replaced` status becomes unreachable through normal flow but stays
in the schema for any future "explicit revise" path that uses it.
This is the single highest-impact fix in this plan. It directly removes
the "plan added twice" behavior the operator reported, and forces the
agent to use the correct advancement tools. Combined with the directive
tool result, even a model that ignores SOUL.md will get the right behavior
because the bad action is refused.
#### A.3 — `propose_plan` tool description: state the contract crisply
**File:** `internal/mcp/server.go` (the `propose_plan` tool schema)
**How:** Replace the current description with a one-paragraph contract:
```
Propose the full ordered plan for this task. Call ONCE per task, before
any execution. After this call: STOP and wait for operator approval.
Once a step has started (status=running/done/...), this tool REFUSES
further calls — use update_plan_step + run to advance. The LAST step
MUST be "Write back: update_entity_attributes + create_relationship
+ upsert_knowledge".
```
This puts the contract where the model reads it (in the tool schema that
gets serialized into the system prompt), not just in SOUL.md where it
competes with three overlapping sections.
#### A.4 — PlanProgress: verify grouping renders with the wired-up column
**File:** `web/src/lib/components/PlanProgress.svelte:17-90`
Once A.1 lands, the grouping code that already exists should work. Verify:
- Latest generation (`Math.max(...generations)`) → expanded, labeled
"Current plan".
- Older generations → collapsed by default, labeled "Plan v1 (replaced)",
with a count badge.
- A future explicit-revise path (not in this plan) would land generation 2
as the new "Current plan" and the old steps collapse.
This is verification, not new code — the structure is there, it just
never received varied generation numbers to group on.
### Phase B — Close the three-bug chain that caused the divergence (addresses #3a, #1, #8)
Phase A.2 (refuse re-proposal) prevents the *symptom* (duplicate plan in
sidebar). This phase fixes the *cause* — the three bugs in finding #3a that
made the agent re-propose in the first place. Each fix targets one link in
the chain.
#### B.1 — Emit `done` after `error` so the frontend doesn't misclassify (fixes bug 2 — the amplifier)
**File:** `cmd/nomos/agent.go:370-371` (+ the other early-return error paths
at lines 348, 356)
**What:** On empty response, `chatWith` emits `error` and returns **without
emitting `done`**. The `done` event only fires on the success path
(agent.go:382). The frontend's `onComplete` (chat.ts:349-356) sees
`!receivedDone` and routes into `handleDisconnect` — treating a *model*
failure as a *network* drop, which triggers an unwanted auto-reconnect →
`resumeSession` → re-proposal.
**How:**
1. After the `error` emit at line 370, also emit `done` before returning:
```go
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID, "correlation_id": correlationID,
"iterations": i + 1, "error": true,
}, SessionID: sessionID})
return
```
2. Do the same for the other early-return error paths (agent.go:348 stream
error, agent.go:356 no choices) so every terminal path emits `done`.
3. On the frontend, `onComplete` (chat.ts:349-356) now sees
`receivedDone === true` and sets `streaming.set(false)` instead of
calling `handleDisconnect`. The error is still shown via the `error`
event handler (chat.ts:329-331).
4. Add `"error": true` to the done payload so the frontend can distinguish
"ended cleanly" from "ended with error" (e.g. to show a retry button
instead of loading dots).
**Impact:** This alone prevents the unwanted `resumeSession` call after a
model empty-response. The error becomes a visible chat error (with the
retry button from prior Phase 1.6), not a silent trigger for re-execution.
This is the single highest-leverage fix in this phase — it breaks the chain
at the amplifier.
#### B.2 — Reconnect note: reference the user's last message and plan state (fixes bug 3 — the divergence)
**File:** `cmd/nomos/main.go:180` (reconnect note) + the other resume entry
points at `main.go:341` (`/resume` endpoint) and `continue.go:83-86`
(idle-sweep note)
**What:** Even with B.1, genuine network disconnects will still happen. When
they do, the reconnect note (`"report your current state and progress"`) is
too generic — it doesn't tell the agent what the operator actually wanted,
so the agent guesses (badly). The note should carry the operator's last
message and whether a plan is in flight.
**How:**
1. Add two helpers to `store.go`:
```go
func (s *store) lastUserMessage(ctx, sessionID) string // SELECT text FROM messages WHERE session_id=$1 AND role='user' ORDER BY created_at DESC LIMIT 1
func (s *store) hasPlanInFlight(ctx, sessionID) bool // SELECT EXISTS(... WHERE session_id=$1 AND status IN ('pending','running'))
```
2. In `handleChat`'s reconnect path (main.go:177-188), build a specific note:
```go
lastUserMsg := st.lastUserMessage(pctx, req.SessionID)
planInFlight := st.hasPlanInFlight(pctx, req.SessionID)
note := fmt.Sprintf("[System: the operator's connection was re-established. "+
"The operator's last message was: \"%s\". ", lastUserMsg)
if planInFlight {
note += "A plan is in flight — advance it with update_plan_step + run. Do NOT call propose_plan again."
} else {
note += "Report your current state and progress."
}
note += "]"
```
3. Apply the same enrichment to the `/resume` endpoint note (main.go:341)
and the idle-sweep note (continue.go:83-86) — all three resume entry
points should carry the same context.
**Impact:** Even if B.1 is bypassed (genuine disconnect mid-plan), the agent
gets "advance the plan" instead of "report state." No more re-proposal from
reconnect.
#### B.3 — `resumeSession`: escalate the recovery note across attempts (fixes bug 1 — the trigger)
**File:** `cmd/nomos/continue.go:229-253`
**What:** The current loop retries 3 times with the same note. A transient
model issue (or a prompt causing the model to no-op) gets three identical
empty responses.
**How:**
1. Build a different `note` per attempt:
```go
notes := []string{
note, // attempt 0: the original (now enriched per B.2) note
fmt.Sprintf("[System: your previous turn produced no response. %s. "+
"Produce a response now — call the next tool or report progress in one sentence.]", note),
fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. "+
"The next action is: pick the lowest-pending plan step, mark it running with "+
"update_plan_step, and call run for its target. Do that now.]"),
}
```
2. Pass `notes[attempt]` to `chatWith` so each retry gets a progressively
more directive prompt.
3. Keep the 3-attempt cap.
**Impact:** A model that's transiently flaking or confused gets a real
second chance with an increasingly specific directive, instead of three
identical prompts.
#### B.4 — Surface the real model error text (addresses finding #1's observability)
**File:** `cmd/nomos/agent.go:370` + `cmd/nomos/continue.go:255-274`
**What:** The operator-facing message is "Nomos returned an empty or
unusable response — please retry." The actual error (OpenRouter 503,
content filter, token limit) is logged but not shown.
**How:**
1. In `chatWith`'s error emit (agent.go:370), include `errText`:
```go
emit(agentEvent{Type: "error", Data: fmt.Sprintf("Nomos returned an empty or unusable response: %s", errText), SessionID: sessionID})
```
2. In `resumeSession`'s failure path (continue.go:262):
```go
resumeFailedNote := fmt.Sprintf(
"[System: auto-resume failed after 3 attempts. Last error: %s. "+
"The task is paused — send another message to continue.]", errText)
```
3. The operator can now tell "model overloaded, just retry" from "content
filter — I need to rephrase."
#### B.5 — Back off between resume retries
**File:** `cmd/nomos/continue.go:229`
**How:** Add a small sleep before attempts 1 and 2:
```go
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-cctx.Done(): return
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
}
}
// ... existing body, using notes[attempt] from B.3
}
```
#### B.6 — Don't persist the empty placeholder as a visible bubble
**File:** `cmd/nomos/main.go:251-266` (handleChat placeholder) +
`cmd/nomos/continue.go:190-218` (resumeSession placeholder)
**What:** On `d9cdcee1` msg 7, the empty assistant bubble persisted in the
transcript because `persist()` ran with `finalText=""` after the error
return. The UI shows an empty bubble.
**How:**
1. Mark the placeholder as pending:
`{"role":"assistant","text":"","pending":true}` instead of just `""`.
2. The frontend renders `pending: true` as loading dots (it already does
this for empty text during streaming), not an empty bubble.
3. On success, `persist()` overwrites with real content and drops `pending`.
4. In `handleChat`'s final persist call (main.go:282), if `finalText == ""`
and `len(toolCalls) == 0`, delete the placeholder row instead of
persisting an empty bubble:
```go
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
st.deleteMessage(pctx, msgID)
} else {
persist()
}
```
### Phase C — Stop the agent re-executing on clarification (addresses #4, #5)
#### C.1 — `completeTask`: reject re-completion of a terminal session
**File:** `cmd/nomos/store.go:completeTask`
**How:**
1. Before the UPDATE, fetch the current status. If it's already `done`,
`failed`, or `partial`, return without re-updating and surface a no-op
message:
```go
var current string
s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id=$1`, sessionID).Scan(&current)
if current == "done" || current == "failed" || current == "partial" {
return nil // already terminal — silently no-op
}
```
Or, stronger, return an error from `completeTask` and have the caller
(tasks.go:275) translate it into a tool-result message:
`"Session is already complete (status=done). If you want to keep working, call update_plan_step + run; do not call complete_task again."`
2. The error path is preferred — the agent sees it in the tool result and
stops trying to re-complete.
#### C.2 — SOUL.md: handle "the UI is inconsistent" complaints without re-executing
**File:** `nomos/SOUL.md`
**How:** Add a short rule:
```
If the operator points out that the chat and the sidebar/plan panel disagree,
DO NOT re-run the work. Investigate the discrepancy by reading state:
get_plan_steps / list current step states → reconcile with a single
update_plan_step call. If the panel is correct and the chat is stale,
summarize the panel in your reply. If the chat is correct and the panel
is stale, fix the panel with update_plan_step. Never re-execute tool
work just to fix a display mismatch.
```
### Phase D — Writeback enforcement that actually sticks (addresses #7)
The current warnings are too easy to ignore because they appear after the
agent has already moved on mentally. Make them structural.
#### D.1 — `completeTask`: refuse to mark success without writeback when state was discovered
**File:** `cmd/nomos/store.go:completeTask` + `cmd/nomos/tasks.go:254-282`
**How:** Convert the warning into a refusal when the session actually ran
discovery tools:
1. Extend `hadEntityWriteback` (store.go:624) into `hadDiscoveryAndWriteback`:
```sql
-- did the session run discovery?
SELECT EXISTS(SELECT 1 FROM audit_log
WHERE session_id=$1 AND tool_name IN ('run','get_entity','get_relations','list_lxcs','list_entities'))
-- AND did it write back?
SELECT EXISTS(SELECT 1 FROM audit_log
WHERE session_id=$1 AND tool_name IN ('update_entity_attributes','create_relationship'))
```
2. In `completeTask`, if `discovery=true AND writeback=false` AND `outcome`
is `success`:
- **Force-downgrade** the outcome to `partial`.
- Return a hard error (not just a warning) that the agent must act on:
`"Refused: this session ran discovery (run/get_entity/...) but did not call update_entity_attributes or create_relationship. Call those now to persist the facts you learned, then call complete_task again. Outcome downgraded to 'partial' until you do."`
3. The agent gets the error in the tool result, sees the directive, and is
forced to call `update_entity_attributes` before it can complete.
This is the structural version of 5.4/5.5 from the prior plan — warnings
didn't work; enforcement will.
#### D.2 — `propose_plan`: auto-append a writeback step if missing
**File:** `cmd/nomos/tasks.go:206-219`
**How:** Instead of (or in addition to) the warning string, append a
synthetic writeback step when none of the proposed steps mention
`update_entity_attributes`:
```go
hasWritebackStep := false
for _, s := range steps {
if strings.Contains(s.Title+s.Detail, "update_entity_attributes") ||
strings.Contains(s.Title+s.Detail, "create_relationship") {
hasWritebackStep = true
break
}
}
if !hasWritebackStep {
steps = append(steps, planStepInput{
Title: "Write back entity attributes and relationships",
Detail: "Call update_entity_attributes for every entity you ran run/get_entity against (versions, states, hosts, IPs), and create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities.",
})
// re-call proposePlan with the extended steps, or append directly to the
// already-persisted plan via a second INSERT.
}
```
The agent then sees the explicit step in its own plan and the seq-order
enforcement (5.6) forces it to complete that step last.
### Phase E — Reduce turn-1 fan-out (addresses #6)
The `722d8878` failure session spent 41 tool calls re-discovering what was
already in the DB.
#### E.1 — SOUL.md: prefer knowledge over re-execution
**File:** `nomos/SOUL.md`
**How:** Add to the discovery section:
```
BEFORE calling `run` for fleet-wide facts (apt counts, service versions,
host states), call search_knowledge and get_knowledge_content for the
relevant entity or topic. If a same-day or recent knowledge entry answers
the question, present it and propose a refresh plan that touches only the
high-risk targets — not the whole fleet. Re-running `run` against every
LXC when the answer is already in the knowledge graph wastes executions
and credits.
```
#### E.2 — `list_lxcs`: include last-audited hint in the result
**File:** `internal/mcp/server.go:list_lxcs` handler
**How:** When returning LXCs, include for each row the most recent
`knowledge_entities.created_at` linked via `about` edges with kind
`investigation` or `document` and a tag matching `audit`/`update`. The
agent then sees "nextcloud — last audited 2026-07-14 (today)" and can skip
re-running it.
This is a smaller tweak than E.1 (which is the load-bearing fix) — the data
hint makes the SOUL.md rule easy to follow.
---
### Phase F — SOUL.md: be crisp, not repetitive (addresses the operator's "more crisp and clear with the agent" feedback)
SOUL.md grew three overlapping sections across the last 20 commits:
| Section | Added by | Says |
|---|---|---|
| `## ⚠️ MANDATORY TASK FLOW` (top) | `5caf49b` (Jul 14) | 6-step flow: set_goal → pre-plan → propose → approve → execute → writeback |
| `## Every chat is a task` (mid) | `e30813a` (Jul 11) | Same 6-step flow, longer, plus the trivial-task degenerate case |
| `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` (inside "Every chat") | `60effcb` (Jul 14) | Writeback rule, third time |
The agent has three places telling it the same thing. The MANDATORY TASK
FLOW section at the top is the right one to keep — it's the most directive
and the closest to the system-prompt boundary. The other two are
lower-fold repetition that bloats context and dilutes the directive.
#### F.1 — Consolidate SOUL.md to one task-flow section
**File:** `nomos/SOUL.md`
**How:**
1. Keep the `## ⚠️ MANDATORY TASK FLOW` section at the top verbatim — it's
the load-bearing version.
2. Replace the `## Every chat is a task` section (lines ~85-165) with a
three-line reference: "Every non-trivial chat follows the MANDATORY
TASK FLOW at the top of this file. The flow scales down: a trivial
read-only question (e.g. 'status of Y?') is a degenerate case — answer
directly and call `complete_task` with a one-line summary, no
propose_plan ceremony."
3. Remove the `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` subsection
entirely — its content is already step 6 of MANDATORY TASK FLOW and
step 3 of "Every chat is a task." Three statements of the same rule
don't make it more enforced; they make the file longer.
4. Result: the file is ~80 lines shorter, the agent has one place to read
the task contract, and the directive is unmissable because it's no
longer competing with two paraphrased copies.
This is reversible prose work, but it directly addresses the operator's
feedback that the agent isn't being "crisp and clear" with itself.
#### F.2 — Make tool-result strings directive, not advisory
**Files:** `cmd/nomos/tasks.go` (the result strings for `set_goal`,
`propose_plan`, `update_plan_step`, `complete_task`)
**How:** Audit each tool-result string for hedging language and tighten:
| Current | Tightened |
|---|---|
| `"Goal set: <goal>. Now do a PRE-PLAN: gather information with read-only tools ... Do NOT call run yet."` | `"Goal set. NEXT: pre-plan (read-only tools only). Then propose_plan. Do not call run."` |
| `"Plan set: N step(s). Now STOP and present the plan to the operator — do NOT call run yet. Wait for them to approve ..."` | `"Plan set (N steps). STOP. Wait for operator approval. Do not call run."` |
| `"Step N → status"` | `"Step N → status. (Use update_plan_step to advance; do not re-propose.)"` — only on the first call per session, otherwise unchanged. |
| `"⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."` | (Replaced by D.1's refusal when discovery ran.) |
Short, imperative, no hedging. The agent's behavior in `d9cdcee1` shows
that long tool-result strings with "consider revising the last step" are
treated as informational; short imperatives ("STOP. Do not call run.")
are followed.
#### F.3 — State the approval vocabulary in the plan-result string
**File:** `cmd/nomos/tasks.go:206-219` (propose_plan result)
**How:** Add the approval vocabulary to the propose_plan result so the
agent recognizes "proceed", "go", "continue", "yes", "approved", "ok" as
approval and does NOT re-propose on those:
```
Plan set (N steps). STOP. Wait for operator approval.
Approval vocabulary: "approved", "yes", "go", "proceed", "continue", "ok".
On approval, advance with update_plan_step + run. Do NOT call propose_plan again.
```
This directly addresses finding #3's cause: the agent re-proposed on
"proceed with the rest" because SOUL.md only listed "approved / yes / go
ahead" as approval vocabulary. Make the list match what operators
actually type.
---
## Sequencing & priority
| # | Fix | Effort | Impact | Phase |
|---|---|---|---|---|
| A.2 | `proposePlan` refuses re-proposal when in flight | S | **Blocker** — directly removes the duplication the operator saw | A |
| B.1 | Emit `done` after `error` in `chatWith` | S | **Blocker** — breaks the three-bug chain at the amplifier | B |
| B.2 | Reconnect note carries user's last message + plan state | S | **Blocker** — fixes the divergence cause | B |
| A.1 | Set `generation` on INSERT | S | High — needed for any future explicit-revise flow | A |
| A.3 | `propose_plan` tool description states the contract | S | High — agent reads tool schema, often ignores SOUL.md | A |
| F.1 | Consolidate SOUL.md to one task-flow section | S | High — addresses "be more crisp" feedback directly | F |
| F.2 | Tighten tool-result strings to imperatives | S | Medium — observable behavior change | F |
| F.3 | Approval vocabulary in propose_plan result | S | High — fixes the "proceed" → empty-response trigger | F |
| B.3 | Escalate recovery note per resume retry | S | High — turns 3 identical empties into a real recovery | B |
| D.1 | Refuse `complete_task` without writeback | M | **Blocker** — fixes the knowledge loop | D |
| D.2 | Auto-append writeback step to plans | M | High — addresses the cause | D |
| B.4 | Surface real model error text | S | Medium — operator can diagnose | B |
| C.1 | Reject re-completion of terminal sessions | S | Medium — stops duplicate `complete_task` | C |
| C.2 | SOUL.md: don't re-execute on UI complaints | S | Medium — prevents the 6 wasted `run` calls | C |
| B.5 | Back off between resume retries | S | Low-medium | B |
| B.6 | Don't persist empty placeholder as bubble | M | Cosmetic — but visible to operators | B |
| A.4 | Verify PlanProgress grouping renders | S | Depends on A.1 | A |
| E.1 | SOUL.md: prefer knowledge over re-execution | S | Medium — saves credits on fleet audits | E |
| E.2 | `list_lxcs` last-audited hint | M | Low — nice-to-have | E |
**Suggested order:** A.2 + B.1 + B.2 (the three blockers, ship together) →
F (crispness, ships alongside) → A.1/A.3/A.4 → D → B.3/B.4/B.5/B.6 → C → E.
The three blockers form a complete fix for the operator's reported bug:
- **A.2** stops the duplication from being *possible* (refuse re-proposal).
- **B.1** stops the empty response from *triggering* a reconnect/resume
(emit `done` after `error`).
- **B.2** makes any *genuine* reconnect carry the right context (advance
the plan, don't re-report).
Together they close the three-bug chain end-to-end. F.3 (approval
vocabulary) closes the *trigger* of the empty response itself.
---
## Verification
After deploying each phase, replay the same operator prompt in a fresh
session and check:
- **Phase A:** Call `propose_plan` twice (manually if needed) and confirm
the sidebar shows "Current plan" + a collapsed "Plan v1 (replaced)"
section, not a flat 10-step list.
- **Phase B:** Force an empty response (e.g. temporarily throttle OpenRouter
to 0 RPM, or use a stub model that returns `""`). Confirm: (a) the
frontend shows the error inline and does NOT trigger a reconnect/resume
(no `auto: true` message appears 15 seconds later); (b) the operator sees
the real error text, not "empty or unusable response"; (c) if you then
disconnect the network for real, the reconnect note says "advance the
plan" (not "report state") and the agent calls `update_plan_step` + `run`,
not `propose_plan`.
- **Phase C:** Start a session, let it `complete_task`, then send a follow-up
complaint. Confirm the agent does NOT call `complete_task` again and does
NOT re-run the original `run` calls.
- **Phase D:** Run a fleet-audit prompt. Confirm the agent cannot reach
`complete_task` with `outcome=success` without first calling
`update_entity_attributes` for at least the LXCs it ran `run` against.
- **Phase E:** Confirm a same-day audit prompt produces a turn-1 with ≤5
tool calls (search_knowledge + get_knowledge_content + small
propose_plan), not 41.
- **Phase F:** Count SOUL.md lines (target: ~80 fewer than current). Replay
the "proceed with the rest" prompt and confirm the agent does NOT call
`propose_plan` again (it gets a refusal error on the call, then advances
via `update_plan_step` + `run`).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
# 2026-07-14 — Tool timeline in sidebar + session analysis
**Status:** Planned
## Session analysis: `1d614a1f` (2026-07-14T09:21)
"Audit all homelab hosts and active LXCs for pending apt updates"
### What went well
- `list_lxcs(state="active")` worked — only returned live LXCs (our Phase 5 fix)
- Agent discovered that `apt-get update` on host targets gets classified as `config_mutation` (modifies apt cache) — all 9 needed approval, user bulk-approved
- 162 tool calls across 4 turns: 30 + 78 + 0 (auto-resume fail) + 54 = legitimate fleet audit
- Our Phase 1 fix worked: 3 auto-resume failures persisted "task is paused" notes instead of auto-failing
### What failed
- **Agent never called `complete_task`.** Session status stuck at `"planning"` despite:
- `set_goal` called in first turn
- 162 tool calls executed
- Final message has full audit text
- But `propose_plan` never cleared the gate — the agent entered planning and never left
- The 3 auto-resume failures filled the transcript with `[System: auto-resume failed…]` noise
### Root cause: `apt-get update` triggers `config_mutation` classification
- The command classifier correctly treats `apt-get update` as state-changing (it writes to the apt cache)
- But the agent just wanted to READ package lists. The audit batch of 9 `apt-get update` calls all needed approval
- Lesson: `apt-get update` should be in a separate audit/update pair where the audit phase uses a read-only inspection command (e.g. `apt list --upgradable` doesn't mutate cache)
### Tool usage pattern
- 30 tool calls in turn 1 (set_goal, propose_plan, list_lxcs, run × 9 for apt update on hosts, update_plan_step × 8, ask_operator)
- 78 tool calls in turn 3 (run × 25+ for apt audits, get_execution_status × 10+, update_plan_step × 10)
- 54 tool calls in turn 7 (final result synthesis)
These 162 tool calls are ALL rendered inline in chat today. The operator sees a massive wall of collapsed ToolCallGroup entries.
---
## Plan
### 1. Scroll fixes (DONE above)
- Activity bar moved to bottom of messages (before messagesEnd)
- Smart scroll: auto-scroll only during streaming OR when user is near bottom
- Scrolling up pauses auto-scroll until user sends a new message
### 2. Tool timeline in sidebar
**Goal:** Decouple tool execution noise from conversation. Chat shows agent's
thinking; sidebar shows what it's doing.
#### 2.1 — Chat: compact tool indicator
Replace the full ToolCallGroup in chat with a single compact line:
```
[N tools used — view in Activity]
```
- Clicking it opens/highlights the sidebar timeline
- Pending approvals still show inline in chat (InlineApproval stays)
- Inline tool renderers (entity cards, health summary, etc.) stay — they're
informational, not noise
#### 2.2 — Sidebar: live tool timeline
The "This session" section (SessionDigest) becomes a live tool timeline:
- Each agent turn gets a timestamp header
- Within each turn: tool calls shown as a compact list with status icons
(running spinner / done check / failed X)
- Tool names are the same compact format from ToolCallGroup
- Results stay collapsible (click to expand)
- Auto-scrolls to latest, but doesn't force-follow if user is reading history
- UPDATEs live (no reload needed) — same polling mechanism as SessionDigest
#### 2.3 — Sidebar: merge plan steps
PlanProgress and SessionDigest merge into one "Activity" panel:
- Top: plan steps with progress bar (from PlanProgress)
- Middle: live tool timeline (from SessionDigest)
- Bottom: knowledge created this session (from SessionDigest)
### 3. `complete_task` enforcement (session finding)
The agent called `set_goal` and then the task entered `planning` status.
But the flow is:
- `set_goal` → status changes to `planning`
- `propose_plan` → status changes to `executing`
The agent called `set_goal` but never `propose_plan` that clears `planning`.
Looking at the code: `setGoal` in store.go sets `status = 'planning'`, and
`proposePlan` sets `status = 'executing'`. So the agent must have called
`set_goal` but the subsequent `propose_plan` failed or the agent skipped it.
**Fix:** In `setGoal`, if the agent has enough information to propose a plan,
auto-transition to `executing` when the first tool call is made (not when
`set_goal` is called — that's too early). The status `planning` should only
stick if the agent explicitly calls `ask_operator` for more info. Otherwise,
status `planning` is indistinguishable from `active` — it just means the
agent never formalized the transition.

View File

@@ -0,0 +1,118 @@
# 2026-07-14 — Unified agent activity indicator
**Status:** Planned
## Current state — three separate indicators
| Component | Location | Shows |
|---|---|---|
| Loading dots (Chat.svelte:146) | Inline in assistant bubble | 3 bouncing dots when no text/tools yet |
| ToolCallGroup trigger row | Inline in assistant bubble | "12 tools" with spinner |
| Activity bar | Bottom of message list | "Agent is responding…" / "Working" / Continue button |
All three overlap. The operator sees dots → then a tool count → then the activity bar — three different visual styles for the same thing: "the agent is working."
## Target: single indicator appended to conversation
One row, always the last item in the message list, that replaces the loading dots, ToolCallGroup summary, and activity bar. Think of it like a system message appended at the end of the conversation.
### Behavior
```
User: "audit fleet"
Assistant: "I'll check all hosts. Here's the plan..." ← full message bubble
┌─ Agent is auditing… ───────────────────┐
│ ◉ apt update on lxc:jellyfin │ ← spinner + current action
└────────────────────────────────────────┘
... agent finishes ...
Assistant: "Done. 19 LXCs have pending updates." ← next message bubble
```
The indicator:
- **Appears** when the agent starts working (first `tool_use` event or `streaming=true`)
- **Updates** its description with the current tool name in flight
- **Collapses/disappears** when the turn ends (`done` event or `streaming=false`)
- If there were tools, shows a brief completion summary for 3 seconds then fades
- During auto-continuation (polling picks up new messages), reappears if the agent did tool calls
### States
| State | Icon | Description |
|---|---|---|
| Thinking | ◉ pulse | "Agent is thinking…" |
| Planning | ◉ pulse | "Building plan…" |
| Researching | ◉ pulse | "Researching <entity>…" |
| Executing | ◉ spinner | "<tool_name> <target>…" |
| Done | ✓ | Fades out after 3s |
### Data source
The description comes from the most recent `tool_use` event's name + args. If no tools yet, show generic "thinking" message. The derived `toolTimeline` store already has this data.
## Implementation
### 1. New component: `AgentIndicator.svelte`
**Props:** `active: boolean`, `lastTool: ToolCallResult | null`, `toolCount: number`
Renders a single compact row:
```html
<div class="activity-indicator">
<LoaderCircle class="animate-spin" /> <!-- or CheckIcon when done -->
<span>{label}</span>
</div>
```
`label` is derived:
```ts
const label = $derived.by(() => {
if (!active) return ''
if (!lastTool) return 'Agent is thinking…'
const args = lastTool.args ?? {}
switch (lastTool.name) {
case 'set_goal': return 'Setting goal…'
case 'propose_plan': return 'Building plan…'
case 'search_knowledge': return `Researching: ${args.query ?? ''}`
case 'get_entity': return `Looking up ${args.slug_or_id ?? ''}`
case 'run': return `${args.purpose ?? 'Running command…'}`
case 'list_lxcs': return 'Listing containers…'
case 'update_plan_step': return 'Updating progress…'
case 'upsert_knowledge': return 'Recording knowledge…'
case 'complete_task': return 'Wrapping up…'
default: return `${lastTool.name}`
}
})
```
### 2. Chat.svelte changes
- **Remove** activity bar from bottom of messages
- **Replace** the 3 bouncing dots `{#if msg.tools.length === 0}` with nothing (the indicator covers this)
- **Add** `<AgentIndicator>` after the `{#each}` loop, before `messagesEnd`
- The indicator shows when `$streaming || liveStatus === 'executing'`
- Pass `lastTool` from `$toolTimeline` — the last tool_use entry
### 3. Remove activity bar code
Delete the `{#if $currentSession && $messages.length > 0}` block at the bottom (already moved once, now deleted entirely — replaced by AgentIndicator).
### 4. Remove loading dots
In Chat.svelte, remove the 3 bouncing dots block:
```svelte
{:else if msg.tools.length === 0}
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
<span class="size-1.5 animate-bounce rounded-full bg-current ...">...</span>
</div>
```
## Verification
- Send "status" → indicator appears "Agent is thinking…" → agent responds → indicator fades
- Send "check updates on jellyfin" → indicator shows "Researching…" → "Listing containers…" → "Running apt list…" → fades
- Auto-continuation fires → indicator reappears with current tool → fades when done
- Scroll up during agent work → indicator stays at bottom of message list (it's just a message)
- Error during agent work → indicator shows "Error: …" with X icon

View File

@@ -1,6 +1,8 @@
# 2026-07-08 — Nomos resident agent (renames Hermes) # 2026-07-08 — Nomos resident agent (renames Hermes)
**Status:** In Progress — N0-N3 complete 2026-07-08 **Status:** Done — 2026-07-11. N0-N3 (rename, agent loop, sessions/streaming,
UI entry point) all verified in current code. N4 (Matrix bridge, proactive
sessions) was explicitly out of scope and remains unstarted.
## Goal ## Goal

View File

@@ -1,6 +1,12 @@
# 2026-07-08 — Plan vs implementation cross-reference # 2026-07-08 — Plan vs implementation cross-reference
**Status:** Planned **Status:** Done — 2026-07-11. Every action this audit recommended has a
corresponding follow-up commit (consolidation `7660e56`, client lifecycle
`efa66c7`/`fcd9f23`/`28ab9b8`, comprehensive audit `43aaf2a`,
DB-as-source-of-truth `a3ebd12`, MCP tool surface `7c6cffb`, apps/105 webhook
cleanup `cefeba7`). Its own Prometheus finding (0% done) still matches the
current state — see [2026-07-05-oikos-prometheus-lxc.md](2026-07-05-oikos-prometheus-lxc.md),
still Planned.
## Goal ## Goal

View File

@@ -1,6 +1,10 @@
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes # 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
**Status:** Planned **Status:** Done — 2026-07-11. All 5 findings fixed on `main`
(`49c37fe fix: chat session reliability, cost, and hygiene`): empty/refusal
retry guard in `agent.go`, bulk-tool guidance in `SOUL.md`, tool-result
truncation in `store.go`, `get_state_snapshot` filtering, and session
delete + generated titles.
## Goal ## Goal

Some files were not shown because too many files have changed in this diff Show More