feat: Phase 3 — MCP header fallback, LIKE-based consent window, and auth token fix for dsh integration
- X-Oikos-Session-Id header → context fallback for _session_id - sessionIDFromArgsOrContext() reads from args, then header - AssentWindowActive falls back to session-ID-only LIKE lookup - windowActiveLike() for LIKE-pattern autonomy_settings queries - mcpBearerToken: package-level resolved token replaces os.Getenv() in decide_approval - Bump 0.36.0 → 0.37.0
This commit is contained in:
@@ -42,12 +42,25 @@ func (g *GovernanceRepo) SessionHasPlan(ctx context.Context, sessionID string) b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AssentWindowActive checks the session-scoped assent window key set by
|
// AssentWindowActive checks the session-scoped assent window key set by
|
||||||
// chat assent and the approval-decide path.
|
// chat assent and the approval-decide path. When no agent is available (dsh),
|
||||||
|
// falls back to a session-ID-only LIKE lookup.
|
||||||
func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool {
|
func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool {
|
||||||
if agentID == "" || sessionID == "" {
|
if sessionID == "" {
|
||||||
return false // fail closed
|
return false // fail closed
|
||||||
}
|
}
|
||||||
return g.windowActive(ctx, "assent_window.agent:"+string(agentID)+".session:"+sessionID)
|
// Exact key lookup (nomos path)
|
||||||
|
if agentID != "" {
|
||||||
|
key := "assent_window.agent:" + string(agentID) + ".session:" + sessionID
|
||||||
|
if g.windowActive(ctx, key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Session-ID-only LIKE fallback (dsh path — no agent entity UUID).
|
||||||
|
// Any window with a matching `.session:<id>` suffix is active.
|
||||||
|
if g.windowActiveLike(ctx, "%.session:"+sessionID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// DestructiveWindowActive checks the target+session-scoped destructive
|
// DestructiveWindowActive checks the target+session-scoped destructive
|
||||||
@@ -75,6 +88,22 @@ func (g *GovernanceRepo) windowActive(ctx context.Context, key string) bool {
|
|||||||
return time.Now().UTC().Before(expires)
|
return time.Now().UTC().Before(expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// windowActiveLike checks a LIKE pattern against autonomy_settings keys.
|
||||||
|
// Used by AssentWindowActive for session-ID-only fallback lookups (dsh path).
|
||||||
|
func (g *GovernanceRepo) windowActiveLike(ctx context.Context, pattern string) bool {
|
||||||
|
var expiresStr string
|
||||||
|
err := g.pool.QueryRow(ctx,
|
||||||
|
"SELECT value FROM autonomy_settings WHERE key LIKE $1", pattern).Scan(&expiresStr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expires, err := time.Parse(time.RFC3339, expiresStr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().UTC().Before(expires)
|
||||||
|
}
|
||||||
|
|
||||||
// PendingApprovalCount returns the session's executions at pending_approval.
|
// PendingApprovalCount returns the session's executions at pending_approval.
|
||||||
func (g *GovernanceRepo) PendingApprovalCount(ctx context.Context, sessionID string) int {
|
func (g *GovernanceRepo) PendingApprovalCount(ctx context.Context, sessionID string) int {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
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)
|
sessionID := sessionIDFromArgsOrContext(ctx, args)
|
||||||
if targetSlug == "" || command == "" {
|
if targetSlug == "" || command == "" {
|
||||||
return textResult("error: target and command are required"), nil
|
return textResult("error: target and command are required"), nil
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
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)
|
sessionID := sessionIDFromArgsOrContext(ctx, args)
|
||||||
|
|
||||||
if lxcSlug == "" || container == "" || command == "" {
|
if lxcSlug == "" || container == "" || command == "" {
|
||||||
return textResult("error: lxc_slug, container, and command are required"), nil
|
return textResult("error: lxc_slug, container, and command are required"), nil
|
||||||
@@ -662,7 +662,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
if targetSlug == "" || service == "" {
|
if targetSlug == "" || service == "" {
|
||||||
return textResult("error: target and service are required"), nil
|
return textResult("error: target and service are required"), nil
|
||||||
}
|
}
|
||||||
sessionID, _ := args["_session_id"].(string)
|
sessionID := sessionIDFromArgsOrContext(ctx, args)
|
||||||
var targetID uuid.UUID
|
var targetID uuid.UUID
|
||||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
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
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
@@ -717,7 +717,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
if backup {
|
if backup {
|
||||||
cmd = fmt.Sprintf("pct exec %s -- cp -n %s %s.bak 2>/dev/null || true; %s", pveID, destPath, destPath, cmd)
|
cmd = fmt.Sprintf("pct exec %s -- cp -n %s %s.bak 2>/dev/null || true; %s", pveID, destPath, destPath, cmd)
|
||||||
}
|
}
|
||||||
sessionID, _ := args["_session_id"].(string)
|
sessionID := sessionIDFromArgsOrContext(ctx, args)
|
||||||
var hostEntityID uuid.UUID
|
var hostEntityID uuid.UUID
|
||||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", "host:"+hostSlug).Scan(&hostEntityID); err != nil {
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", "host:"+hostSlug).Scan(&hostEntityID); err != nil {
|
||||||
// hostSlug may already carry the host: prefix
|
// hostSlug may already carry the host: prefix
|
||||||
@@ -800,8 +800,8 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
return textResult(fmt.Sprintf("error: %v", hreqErr)), nil
|
return textResult(fmt.Sprintf("error: %v", hreqErr)), nil
|
||||||
}
|
}
|
||||||
hreq.Header.Set("Content-Type", "application/json")
|
hreq.Header.Set("Content-Type", "application/json")
|
||||||
if token := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); token != "" {
|
if mcpBearerToken != "" {
|
||||||
hreq.Header.Set("Authorization", "Bearer "+token)
|
hreq.Header.Set("Authorization", "Bearer "+mcpBearerToken)
|
||||||
}
|
}
|
||||||
resp, reqErr := client.Do(hreq)
|
resp, reqErr := client.Do(hreq)
|
||||||
if reqErr != nil {
|
if reqErr != nil {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
|||||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler {
|
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler {
|
||||||
|
mcpBearerToken = token
|
||||||
s := newServer(pool, agentID, sec, entities, relService, execSvc)
|
s := newServer(pool, agentID, sec, entities, relService, execSvc)
|
||||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||||
if token != "" {
|
if token != "" {
|
||||||
@@ -62,7 +63,35 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}, nil)
|
}, nil)
|
||||||
return handler
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract X-Oikos-Session-Id from headers and store in context.
|
||||||
|
if sid := r.Header.Get("X-Oikos-Session-Id"); sid != "" {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), ctxSessionIDKey{}, sid))
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctxSessionIDKey is a Go context key for X-Oikos-Session-Id header value.
|
||||||
|
type ctxSessionIDKey struct{}
|
||||||
|
|
||||||
|
// mcpBearerToken is the resolved MCP bearer token, set at startup from the
|
||||||
|
// config (after Infisical overlay). Used by decide_approval and similar
|
||||||
|
// handlers that call back into the oikos HTTP API.
|
||||||
|
var mcpBearerToken string
|
||||||
|
|
||||||
|
// sessionIDFromArgsOrContext returns _session_id from tool call args,
|
||||||
|
// falling back to the X-Oikos-Session-Id header injected into the request
|
||||||
|
// context. This lets dsh agents send the session ID as a header without
|
||||||
|
// injecting it into every tool call's arguments.
|
||||||
|
func sessionIDFromArgsOrContext(ctx context.Context, args map[string]any) string {
|
||||||
|
if sid, _ := args["_session_id"].(string); sid != "" {
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
if sid, ok := ctx.Value(ctxSessionIDKey{}).(string); ok {
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolHandler is the function signature registered via AddTool.
|
// toolHandler is the function signature registered via AddTool.
|
||||||
|
|||||||
285
plans/2026-08-16-dsh-as-agent-replace-nomos.md
Normal file
285
plans/2026-08-16-dsh-as-agent-replace-nomos.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# dsh-as-agent: replace nomos with DeepSeek Harness
|
||||||
|
|
||||||
|
**Date:** 2026-08-16
|
||||||
|
**Status:** Active
|
||||||
|
**Scope:** `cmd/nomos/` → dsh sidecar; oikos stays as Go backend behind MCP
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
Replace the nomos agent (`cmd/nomos/`, ~5,500 LOC) with [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh), a TypeScript/Cordis agent harness where everything is a plugin — model adapters, tool registry, agent loop, session log, Web UI. dsh runs as a Node.js sidecar alongside the oikos API, connecting via the existing MCP interface (67+ tools). Custom dsh plugins bridge oikos's Postgres-backed session model, policy engine, and approval gating.
|
||||||
|
|
||||||
|
oikos-web (Svelte 5 SPA) is replaced by dsh's built-in Web UI. The Go backend remains untouched — dsh is purely an agent/UI replacement.
|
||||||
|
|
||||||
|
## 2. Session persistence: Postgres vs dsh SQLite
|
||||||
|
|
||||||
|
dsh ships with `@deepseek-ai/dsh-session` backed by JSONL or SQLite (event-sourced log). oikos uses flat Postgres tables (`agent_sessions`, `agent_messages`, `agent_activity`, `session_plan_steps`, etc.) with ~2,200 lines of domain logic in `internal/nomos/session/store.go`.
|
||||||
|
|
||||||
|
| | dsh SQLite (default) | oikos Postgres |
|
||||||
|
|---|---|---|
|
||||||
|
| **Model** | Event-sourced: every event appended, `deriveMessages()` projects model history | Flat: typed tables, aggregate columns (message_count, tool_call_count), precomputed views |
|
||||||
|
| **Tightness with oikos data** | dsh owns session data in isolation; oikos backend cannot JOIN across sessions→entities | Session data lives in oikos Postgres alongside entities, executions, knowledge, events — one FK graph |
|
||||||
|
| **Cross-cutting queries** | dsh would need its own API for "all sessions touching host X" | `SELECT ... FROM agent_messages JOIN entities ...` works directly — no bridge |
|
||||||
|
| **Auto-upsert knowledge** | dsh would replicate nomos's `autoUpsertKnowledge` logic | Direct `INSERT INTO knowledge_entities` in the same DB — atomic, no network hop |
|
||||||
|
| **Agent activity/audit** | dsh would replicate `agent_activity` table writes | Already exists: `agent_activity` with entity FK, tool_name, success, duration, tokens |
|
||||||
|
| **Plan + execution linking** | dsh would replicate `nomos_plan_executions` join table | Already exists: `executions` ↔ `session_plan_steps` ↔ `agent_sessions` |
|
||||||
|
| **dsh ecosystem compatibility** | Full — dsh's event-sourced model, built-in compaction, fork, replay, persistence seams all work out of the box | Partial — must write a custom persistence plugin implementing dsh's `session-persistence` seam against Postgres |
|
||||||
|
| **Session events (chunks, boundaries)** | dsh stores raw `assistant/chunk` and `turn/start`/`turn/end` events for faithful replay | oikos stores only the rolled-up `assistant`/`tool` messages — loses per-chunk granularity |
|
||||||
|
| **Migration cost** | None — dsh owns its storage | Medium — must write the Postgres persistence plugin (~1 week) |
|
||||||
|
| **DB schema churn** | None | Adds migration files for dsh's event-sourced log format alongside existing flat tables |
|
||||||
|
|
||||||
|
**Verdict:** Postgres is the right choice. The tight coupling with entities, knowledge, executions, and the event bus (`observability.Event` via PG `NOTIFY`) is too valuable to sever — it's what makes oikos oikos rather than a generic agent host. The cost is writing a custom `dsh-session-persistence-postgres` plugin that maps dsh's event-sourced `SessionEvent` log onto Postgres rows, while preserving enough granularity for dsh's `deriveMessages()` to reconstruct model history faithfully. The oikos flat tables (`agent_sessions`, `agent_messages`) become projections/views over the event log, maintained for backward compat with the REST API.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐ MCP (JSON-RPC over HTTP)
|
||||||
|
│ dsh (Node.js) │ ◄────────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────────────────────┐ │ │
|
||||||
|
│ │ dsh-base bundle │ │ │
|
||||||
|
│ │ - dsh-agent-loop (turn/step) │ │ │
|
||||||
|
│ │ - dsh-llm-deepseek (model) │ │ │
|
||||||
|
│ │ - dsh-tools (tool pipeline) │ │ │
|
||||||
|
│ │ - dsh-session (event log) │ │ │
|
||||||
|
│ │ - dsh-interaction (approvals) │ │ │
|
||||||
|
│ │ - dsh-web-app (built-in UI) │ │ │
|
||||||
|
│ └───────────────────────────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────────────────────┐ │ │
|
||||||
|
│ │ oikos dsh plugins │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ @oikos/dsh-mcp-tools │──┤ tools/list + tools/call │
|
||||||
|
│ │ → discovers 67+ tools via MCP │ │ to oikos MCP server │
|
||||||
|
│ │ → ctx.tools.register() each │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ @oikos/dsh-policy │──┤ classify_command / │
|
||||||
|
│ │ → tools/pre-execute listener │ │ preflight MCP tools │
|
||||||
|
│ │ → calls oikos classification │ │ │
|
||||||
|
│ │ → returns allow/deny/ask │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ @oikos/dsh-session-pg │──┤ INSERT/UPDATE/SELECT │
|
||||||
|
│ │ → implements session- │ │ on oikos Postgres │
|
||||||
|
│ │ persistence seam vs Postgres │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ @oikos/dsh-task-tools │ │ (same Postgres pool) │
|
||||||
|
│ │ → set_goal, propose_plan, etc. │──┤ │
|
||||||
|
│ │ → writes oikos tables directly │ │ │
|
||||||
|
│ │ → emits oikos observability │ │ │
|
||||||
|
│ │ Events for SSE fan-out │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ @oikos/dsh-experiences │ │ │
|
||||||
|
│ │ → homelab-specific UI nodes │ │ │
|
||||||
|
│ │ and workflows │ │ │
|
||||||
|
│ └───────────────────────────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ dsh Web UI (replaces oikos-web) │ │
|
||||||
|
│ → Serves at :3080 (dsh default) │ │
|
||||||
|
│ → oikos pages migrated as dsh │ │
|
||||||
|
│ ConversationNodes + custom views │ │
|
||||||
|
└─────────────────────────────────────────┘ │
|
||||||
|
│
|
||||||
|
┌───────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ oikos (Go service — unchanged) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ MCP server: 67+ tools │ │ REST API (chi) │ │
|
||||||
|
│ │ - Entity, Ops, │ │ - /api/v1/entities │ │
|
||||||
|
│ │ Knowledge, Analysis │ │ - /api/v1/executions │ │
|
||||||
|
│ └──────────────────────┘ │ - /api/v1/knowledge │ │
|
||||||
|
│ │ - health, metrics, etc. │ │
|
||||||
|
│ ┌──────────────────────┐ └──────────────────────────┘ │
|
||||||
|
│ │ Policy engine │ │
|
||||||
|
│ │ (internal/policy) │ ┌──────────────────────────┐ │
|
||||||
|
│ └──────────────────────┘ │ Postgres │ │
|
||||||
|
│ │ - entities, relationships │ │
|
||||||
|
│ ┌──────────────────────┐ │ - executions, signals │ │
|
||||||
|
│ │ Scheduler + probes │ │ - agent_sessions, messages│ │
|
||||||
|
│ └──────────────────────┘ │ - knowledge_entities │ │
|
||||||
|
│ │ - events (SSE NOTIFY) │ │
|
||||||
|
│ ┌──────────────────────┐ │ - agent_activity │ │
|
||||||
|
│ │ Secrets (Infisical) │ └──────────────────────────┘ │
|
||||||
|
│ └──────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Phases
|
||||||
|
|
||||||
|
### Phase 1: Scaffold + MCP tool bridge (2 weeks)
|
||||||
|
|
||||||
|
**Goal:** dsh boots, connects to oikos MCP, agents run real tasks through oikos tools.
|
||||||
|
|
||||||
|
1. **Create `oikos-dsh/` monorepo** alongside `oikos-web/`
|
||||||
|
- Workspace root with pnpm, `tsconfig`, `vitest`
|
||||||
|
- `oikos-dsh/bundles/oikos-profile/` — profile YAML composing `dsh-base` + oikos plugins
|
||||||
|
- `oikos-dsh/plugins/` — custom plugins directory
|
||||||
|
|
||||||
|
2. **`@oikos/dsh-mcp-tools` plugin**
|
||||||
|
- On `apply(ctx)`: connect to oikos MCP (configured URL + token)
|
||||||
|
- Call `tools/list`, for each tool: `ctx.tools.register(mcpToolDef)`
|
||||||
|
- On execute: forward to oikos `tools/call`, stream result back
|
||||||
|
- Handle `_session_id` scoping for concurrent task isolation
|
||||||
|
- Cache tool list, invalidate on reconnect
|
||||||
|
|
||||||
|
3. **Boot dsh with the profile**
|
||||||
|
- `dsh --profile oikos`
|
||||||
|
- Validate connectivity at startup
|
||||||
|
- Run `get_entity`, `list_entities` through dsh → confirm roundtrip
|
||||||
|
|
||||||
|
4. **Port the golden evals**
|
||||||
|
- `evals/golden.yaml` → dsh vitest test suite
|
||||||
|
- Assert tool calls, completion, plan structure same as nomos
|
||||||
|
- Gate: 4/4 golden evals pass (trivial_readonly, plan_advances_on_proceed, ui_complaint_no_rerun, knowledge_preferred_over_rerun)
|
||||||
|
|
||||||
|
**Check:** `dsh` agent answers "What is the state of lxc:dns?" through oikos MCP, returns the same answer nomos would.
|
||||||
|
|
||||||
|
### Phase 2: Postgres session persistence (1.5 weeks)
|
||||||
|
|
||||||
|
**Goal:** dsh sessions write to oikos Postgres, not dsh SQLite.
|
||||||
|
|
||||||
|
1. **Design the bridging schema**
|
||||||
|
- Add `session_event_log` table: `(session_id UUID, seq INT, event_type TEXT, payload JSONB, created_at TIMESTAMPTZ)`
|
||||||
|
- This is the event-sourced log dsh needs for `deriveMessages()`
|
||||||
|
- `agent_sessions`, `agent_messages` become materialized projections — updated via triggers or application-level write-through
|
||||||
|
- `agent_activity` stays as-is for the audit/operations views
|
||||||
|
|
||||||
|
2. **`@oikos/dsh-session-pg` plugin** — implements dsh's session-persistence seam
|
||||||
|
- `subscribe("session/event")` → append row to `session_event_log`
|
||||||
|
- `subscribe("session/flush")` → commit/notify
|
||||||
|
- On load: `SELECT * FROM session_event_log WHERE session_id = $1 ORDER BY seq` → rebuild `Session`
|
||||||
|
- Session lifecycle: `session/created` → ensure row in `agent_sessions`; `session/disposed` → finalize outcome/summary
|
||||||
|
- Use oikos `pgxpool` (via Node.js `pg` module)
|
||||||
|
|
||||||
|
3. **`@oikos/dsh-task-tools` plugin** — replaces nomos's task tools
|
||||||
|
- Register `set_goal`, `propose_plan`, `update_plan_step`, `complete_task`, `ask_operator`
|
||||||
|
- Each writes directly to oikos Postgres tables (`agent_sessions`, `session_plan_steps`, `session_questions`)
|
||||||
|
- Emit oikos `observability.Event` via PG `NOTIFY` for SSE live-updates
|
||||||
|
- Mirror nomos's business logic: auto-append writeback step, refuse complete without writeback, plan generation tracking, completion ordering, auto-complete-if-plan-done safety net
|
||||||
|
|
||||||
|
4. **Verify against evals** — all golden evals pass through dsh with Postgres persistence
|
||||||
|
|
||||||
|
**Check:** Create a session in dsh, verify `agent_sessions` and `agent_messages` rows appear in oikos Postgres. Read them back from oikos REST API.
|
||||||
|
|
||||||
|
### Phase 3: Policy bridge (1 week)
|
||||||
|
|
||||||
|
**Goal:** dsh respects oikos risk classification and approval gating.
|
||||||
|
|
||||||
|
1. **`@oikos/dsh-policy` plugin**
|
||||||
|
- Listen on `tools/pre-execute` waterfall
|
||||||
|
- For each tool call: call oikos `preflight` or `classify_command` MCP tool
|
||||||
|
- Map risk class to dsh decision:
|
||||||
|
- `readonly` → `allow` (no gate)
|
||||||
|
- `reversible_low` → `allow` (auto-execute, same as nomos)
|
||||||
|
- `config_mutation` → check oikos assent window; if active → `allow`, else → `ask`
|
||||||
|
- `destructive` → check oikos destructive window; if active → `allow`, else → `ask` with typed-confirmation requirement
|
||||||
|
- `ask` returns a dsh `Interaction` — the UI shows an approval dialog; operator decides → tool continues or is denied
|
||||||
|
|
||||||
|
2. **Backend:** no changes needed — oikos MCP `classify_command` and `preflight` already exist
|
||||||
|
|
||||||
|
**Check:** A `run` call with `config_mutation` risk triggers an approval dialog in dsh UI. "Go ahead" in chat grants it.
|
||||||
|
|
||||||
|
### Phase 4: UI migration (3-4 weeks)
|
||||||
|
|
||||||
|
**Goal:** dsh Web UI replaces oikos-web.
|
||||||
|
|
||||||
|
1. **dsh Web UI basics**
|
||||||
|
- dsh ships its own Web UI: session list, chat window with tool cards, assistant chunks, turn/step boundaries
|
||||||
|
- No changes needed for basic agent chat — it works out of the box
|
||||||
|
|
||||||
|
2. **Custom ConversationNodes for oikos pages**
|
||||||
|
- **Entity Graph page** — reimplement sigma.js graph as a dsh Web Client plugin
|
||||||
|
- ConversationNode listens for tool/call events, renders entity graph
|
||||||
|
- Health/Type color mode toggle, filter presets (All, Problems, Infra)
|
||||||
|
- Port `EntityGraph.svelte`'s logic to a dsh conversation node
|
||||||
|
- **Operations page** — execution list, approval management
|
||||||
|
- Use dsh's existing `interaction` UI for approvals
|
||||||
|
- Custom node for execution history + systemctl status
|
||||||
|
- **Knowledge page** — wiki browser, search, quick-open
|
||||||
|
- dsh already has `search_knowledge` tool; add a Knowledge conversation node
|
||||||
|
- Port `WikiTree`, `WikiReader`, `WikiOverview` from oikos-web
|
||||||
|
- **Signals page** — signal list, ack/mute/resolve
|
||||||
|
- Custom node reading from oikos REST API (via dsh `agent.inject` or API call)
|
||||||
|
- **Overview/Dashboard** — fleet summary, health counts
|
||||||
|
- dsh `get_health_summary` already exists; render as dashboard cards
|
||||||
|
- **Config page** — API token, server URL, theme settings
|
||||||
|
- dsh has `settings` and `credentials` seams; hook into them
|
||||||
|
- **Desktop shell / mascot** — app launcher, dock, taskbar, Cluck mascot
|
||||||
|
- dsh has no desktop paradigm — either skip the shell or implement as a ConversationNode
|
||||||
|
- Mascot can be ported as a persistent UI element
|
||||||
|
|
||||||
|
3. **Route mapping**
|
||||||
|
| oikos-web page | dsh equivalent |
|
||||||
|
|---|---|
|
||||||
|
| Overview.svelte | Custom dashboard ConversationNode |
|
||||||
|
| EntityGraph.svelte | Custom entity-graph ConversationNode |
|
||||||
|
| Ops.svelte | Custom operations ConversationNode |
|
||||||
|
| Signals.svelte | Custom signals ConversationNode |
|
||||||
|
| Knowledge.svelte / KnowledgeBase.svelte | Custom knowledge ConversationNode |
|
||||||
|
| Config.svelte | dsh settings/credentials |
|
||||||
|
| Chat session | Built-in dsh chat window |
|
||||||
|
| Learning.svelte | Custom learning ConversationNode |
|
||||||
|
| AppStore.svelte | Custom app-store ConversationNode |
|
||||||
|
|
||||||
|
4. **CSS theme migration**
|
||||||
|
- oikos uses dark terminal aesthetic (cyberspace theme, amber/green, dithered images)
|
||||||
|
- dsh has its own light/dark theme — customize via CSS overrides in the profile
|
||||||
|
- Port the GlyphIndicator, MascotLayer, and other visual signatures
|
||||||
|
|
||||||
|
**Check:** All major oikos-web pages have a functional equivalent in dsh UI. Entity graph renders with force layout and health coloring.
|
||||||
|
|
||||||
|
### Phase 5: Experiences as plugins (ongoing)
|
||||||
|
|
||||||
|
With the bridge complete, "experiences" are standard dsh plugins registered in the profile:
|
||||||
|
|
||||||
|
| Plugin | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `@oikos/dsh-incident-response` | Guided workflow: detect signal → classify → `run` remediation → verify → document with `upsert_knowledge`. Uses dsh `plan-mode` for structured steps. |
|
||||||
|
| `@oikos/dsh-infra-deploy` | Provision LXCs with blast radius visualization. Pre-flight check via `get_blast_radius`, then step-by-step `run` with approval gates. |
|
||||||
|
| `@oikos/dsh-knowledge-autosync` | Background `ctx.jobs` that periodically audits knowledge gaps (orphan docs, stale entities) and suggests upserts. |
|
||||||
|
| `@oikos/dsh-session-review` | Port the `session-review` skill from `.agents/skills/` to a dsh tool: given a session ID, analyze transcripts, compare objective to outcome, propose fixes. |
|
||||||
|
| `@oikos/dsh-fleet-dashboard` | Real-time fleet health with drill-down. Uses oikos SSE event stream + dsh Web Client custom rendering. |
|
||||||
|
|
||||||
|
Each plugin:
|
||||||
|
- Registers tools on `ctx.tools` (model-visible capabilities)
|
||||||
|
- Registers ConversationNodes on the Web Client (UI components)
|
||||||
|
- Listens on `agent/*` or `session/event` for reactive behavior
|
||||||
|
- Is independently versioned and hot-loadable via Cordis
|
||||||
|
|
||||||
|
## 5. Deleted code
|
||||||
|
|
||||||
|
On completion of Phases 1-3, the following oikos code is decommissioned:
|
||||||
|
|
||||||
|
- `cmd/nomos/` — entire directory (~5,500 LOC): agent.go, server.go, mcp.go, store.go (the old flat store), assent.go, continue.go, tasks.go, turngate.go, messagequeue.go, retrycap.go, plus tests
|
||||||
|
- `nomos/` — SOUL.md, config.yaml, skills/
|
||||||
|
- `internal/nomos/session/` — moved to dsh plugin, but the domain types and some logic may be extracted into a shared `oikos-dsh` npm package
|
||||||
|
- `internal/httpapi/` chat-related endpoints — replaced by dsh's own agent session endpoints
|
||||||
|
- `compose/web/` — web service in docker-compose (served oikos-web SPA)
|
||||||
|
- `desktop/` — Wails desktop wrapper (dsh Web UI is a PWA, no native wrapper needed)
|
||||||
|
|
||||||
|
The following oikos code stays:
|
||||||
|
- `internal/httpapi/` — REST API for entities, executions, knowledge, signals, health
|
||||||
|
- `internal/mcp/` — the 67+ MCP tools (now serving dsh instead of nomos)
|
||||||
|
- `internal/policy/` — risk classification engine
|
||||||
|
- `internal/scheduler/` — health checks, metrics, probes
|
||||||
|
- `internal/secrets/` — Infisical/SOPS integration
|
||||||
|
- `internal/nomos/assent/`, `internal/nomos/session/` domain types (may be extracted to shared package)
|
||||||
|
|
||||||
|
## 6. Migration path
|
||||||
|
|
||||||
|
The cutover is a rolling deployment:
|
||||||
|
|
||||||
|
1. **Deploy dsh alongside nomos** — both agent runtimes run in parallel during development. `compose/dsh/` joins the docker-compose stack.
|
||||||
|
2. **Port the UI incrementally** — dsh UI and oikos-web coexist on different ports: dsh on `:3080`, oikos-web on `:3000`. The Caddy reverse proxy routes `/chat/*` and `/` to dsh during testing.
|
||||||
|
3. **Switch the default route** — once dsh passes all golden evals and the UI covers the main pages, Caddy routes all traffic to dsh. oikos-web becomes available at `/legacy` during the transition.
|
||||||
|
4. **Cleanup** — remove `cmd/nomos/`, `compose/web/`, `oikos-web` repo (or archive).
|
||||||
|
|
||||||
|
## 7. Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| **dsh breaking changes** — dev preview, no semver | Pin a specific git commit + `pnpm-lock.yaml`. Pin in VERSION file. Stretch: fork the core packages we depend on. |
|
||||||
|
| **Session persistence bridge lag** — dsh expects event-sourced model, oikos has flat tables | Accept dual-write during migration. The `session_event_log` table feeds dsh's `deriveMessages()`; legacy `agent_messages` stays for REST API backward compat until all consumers migrate. |
|
||||||
|
| **UI migration scope** — entity graph, desktop shell, mascot are non-trivial ports | Start with chat + operations (90% of daily use). Entity graph and mascot come after. The old oikos-web stays readable during transition. |
|
||||||
|
| **Golden eval regressions** — subtle behavioral differences between nomos and dsh agent loops | Run evals in CI on every dsh change. Nomos stays deployed until evals pass at parity. |
|
||||||
|
| **Team TS inexperience** — you said TS is OK, but ramp-up for Go developers | Start with small plugins (MCP bridge is ~200 LOC). The dsh extension cookbook is well-documented. |
|
||||||
|
| **Performance** — every tool call crosses TS → HTTP → Go | Same architecture as nomos (which also crossed HTTP). Latency is the same. The MCP server is fast (no serialization overhead beyond JSON). |
|
||||||
|
| **oikos-web features not supported by dsh UI** — desktop shell, window management, mascot | Assess during Phase 4. If the desktop paradigm is essential, implement it as a dsh conversation node (which can render any HTML/CSS) rather than maintaining two UIs. |
|
||||||
Reference in New Issue
Block a user