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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(node --check src/native-agent.js)",
"Bash(npm test *)"
"Bash(npm test *)",
"Bash(xargs cat)"
]
}
}
19 changes: 19 additions & 0 deletions evcod/core/internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,9 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) {
AgentType string `json:"agentType"`
Model string `json:"model"`
PermissionMode string `json:"permissionMode"`
Effort string `json:"effort"`
FastMode *bool `json:"fastMode"`
PlanMode *bool `json:"planMode"`
}
if !decode(w, req, &body) {
return
Expand All @@ -1314,6 +1317,13 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) {
writeResult(w, nil, err)
return
}
// Persist the chosen model/permission mode on the session (without firing a
// model-change inject — the agent picks these up via its launch args).
if strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil {
if updated, cfgErr := r.services.Agent.SetConfig(session.ID, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, "launch"); cfgErr == nil {
session = updated
}
}
args := []string{
"evcod-warp",
session.AgentType,
Expand Down Expand Up @@ -1374,11 +1384,20 @@ func (r *Router) agentSessionByID(w http.ResponseWriter, req *http.Request) {
Status string `json:"status"`
StatusReason string `json:"statusReason"`
ProviderSessionID string `json:"providerSessionId"`
Model string `json:"model"`
PermissionMode string `json:"permissionMode"`
Effort string `json:"effort"`
FastMode *bool `json:"fastMode"`
PlanMode *bool `json:"planMode"`
ConfigSource string `json:"configSource"`
}
if !decode(w, req, &body) {
return
}
session, err := r.services.Agent.Update(id, body.PaneID, body.AgentType, body.Status, body.StatusReason, body.ProviderSessionID)
if err == nil && (strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil) {
session, err = r.services.Agent.SetConfig(id, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, body.ConfigSource)
}
writeResult(w, session, err)
case req.Method == http.MethodPost && action == "lock":
var body struct {
Expand Down
5 changes: 5 additions & 0 deletions evcod/core/internal/domain/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ type AgentSession struct {
ConversationID string `json:"conversationId"`
PaneID *string `json:"paneId,omitempty"`
AgentType string `json:"agentType"`
Model string `json:"model,omitempty"`
PermissionMode string `json:"permissionMode,omitempty"`
Effort string `json:"effort,omitempty"`
FastMode bool `json:"fastMode,omitempty"`
PlanMode bool `json:"planMode,omitempty"`
Status string `json:"status"`
StatusReason string `json:"statusReason,omitempty"`
ProviderSessionID string `json:"providerSessionId,omitempty"`
Expand Down
66 changes: 66 additions & 0 deletions evcod/core/internal/services/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,25 @@ func parseWindowsNetstat(output, protocol string, processNames map[int]string) [
}

func unixPorts(processNames map[int]string) ([]domain.HostPort, error) {
if goruntime.GOOS == "darwin" {
ports, err := lsofPorts(processNames)
if err != nil {
return []domain.HostPort{}, nil
}
return ports, nil
}
ports, err := ssPorts(processNames)
if err == nil {
return ports, nil
}
ports, err = lsofPorts(processNames)
if err != nil {
return []domain.HostPort{}, nil
}
return ports, nil
}

func ssPorts(processNames map[int]string) ([]domain.HostPort, error) {
out, err := runHostCommand(3500*time.Millisecond, "ss", "-tunlp")
if err != nil {
return nil, err
Expand Down Expand Up @@ -1025,6 +1044,53 @@ func unixPorts(processNames map[int]string) ([]domain.HostPort, error) {
return ports, nil
}

func lsofPorts(processNames map[int]string) ([]domain.HostPort, error) {
out, err := runHostCommand(3500*time.Millisecond, "lsof", "-nP", "-iTCP", "-iUDP")
if err != nil {
return nil, err
}
return parseLsofPorts(out, processNames), nil
}

func parseLsofPorts(output string, processNames map[int]string) []domain.HostPort {
ports := []domain.HostPort{}
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) < 9 || fields[0] == "COMMAND" {
continue
}
protocol := strings.ToUpper(fields[7])
if protocol != "TCP" && protocol != "UDP" {
continue
}
nameField := strings.Join(fields[8:], " ")
state := ""
if idx := strings.LastIndex(nameField, " ("); idx >= 0 && strings.HasSuffix(nameField, ")") {
state = strings.TrimSuffix(strings.TrimPrefix(nameField[idx+1:], "("), ")")
nameField = strings.TrimSpace(nameField[:idx])
}
local := strings.TrimSpace(strings.SplitN(nameField, "->", 2)[0])
localAddress, localPort := splitAddressPort(local)
if localPort == "" || localPort == "*" {
continue
}
pid, _ := strconv.Atoi(fields[1])
processName := processNames[pid]
if processName == "" {
processName = fields[0]
}
ports = append(ports, domain.HostPort{
Protocol: protocol,
LocalAddress: localAddress,
LocalPort: localPort,
State: state,
PID: pid,
ProcessName: processName,
})
}
return ports
}

func parseSSProcess(value string) (int, string) {
name := ""
pid := 0
Expand Down
29 changes: 29 additions & 0 deletions evcod/core/internal/services/host_ports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package services

import "testing"

func TestParseLsofPorts(t *testing.T) {
output := `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
rapportd 494 fu 6u IPv4 0x1 0t0 TCP *:50847 (LISTEN)
evcod 1234 fu 20u IPv4 0x2 0t0 TCP 127.0.0.1:10065 (LISTEN)
identity 503 fu 10u IPv4 0x3 0t0 UDP *:*
sharing 517 fu 4u IPv4 0x4 0t0 UDP *:5353
remote 600 fu 9u IPv6 0x5 0t0 TCP [fe80::1]:50847->[fe80::2]:52609 (ESTABLISHED)
`
ports := parseLsofPorts(output, map[int]string{1234: "evcod-core"})
if len(ports) != 4 {
t.Fatalf("unexpected port count: got %d %#v", len(ports), ports)
}
if ports[0].Protocol != "TCP" || ports[0].LocalAddress != "*" || ports[0].LocalPort != "50847" || ports[0].State != "LISTEN" || ports[0].PID != 494 || ports[0].ProcessName != "rapportd" {
t.Fatalf("unexpected first port: %#v", ports[0])
}
if ports[1].ProcessName != "evcod-core" || ports[1].LocalAddress != "127.0.0.1" || ports[1].LocalPort != "10065" {
t.Fatalf("unexpected process-name fallback: %#v", ports[1])
}
if ports[2].Protocol != "UDP" || ports[2].LocalPort != "5353" {
t.Fatalf("unexpected udp port: %#v", ports[2])
}
if ports[3].State != "ESTABLISHED" || ports[3].LocalAddress != "fe80::1" || ports[3].LocalPort != "50847" {
t.Fatalf("unexpected established ipv6 port: %#v", ports[3])
}
}
81 changes: 81 additions & 0 deletions evcod/core/internal/services/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,87 @@ func (s *AgentService) Update(id, paneID, agentType, status, statusReason, provi
return session, nil
}

// SetConfig persists the selected model and turn controls on a session and
// announces the change so the running agent (warp) can sync it into the native
// CLI. Empty string values and nil booleans leave the corresponding field
// untouched. `source` records where the change came from ("web" or "terminal")
// so consumers can avoid echo loops.
func (s *AgentService) SetConfig(id, model, permissionMode, effort string, fastMode, planMode *bool, source string) (domain.AgentSession, error) {
model = strings.TrimSpace(model)
permissionMode = strings.TrimSpace(permissionMode)
effort = strings.TrimSpace(effort)
if source == "" {
source = "web"
}
modelChanged := false
configChanged := false
session, err := s.store.UpdateAgentSession(id, func(session *domain.AgentSession) error {
if model != "" && model != session.Model {
session.Model = model
modelChanged = true
configChanged = true
}
if permissionMode != "" && permissionMode != session.PermissionMode {
session.PermissionMode = permissionMode
configChanged = true
}
if effort != "" && effort != session.Effort {
session.Effort = effort
configChanged = true
}
if fastMode != nil && *fastMode != session.FastMode {
session.FastMode = *fastMode
configChanged = true
}
if planMode != nil && *planMode != session.PlanMode {
session.PlanMode = *planMode
configChanged = true
}
session.UpdatedAt = store.Now()
return nil
})
if err != nil {
return domain.AgentSession{}, err
}
s.publishSession("agent.session.updated", session)
if configChanged || modelChanged {
paneID := ""
if session.PaneID != nil {
paneID = *session.PaneID
}
payload := map[string]any{
"sessionId": session.ID,
"conversationId": session.ConversationID,
"paneId": paneID,
"agentType": session.AgentType,
"model": session.Model,
"permissionMode": session.PermissionMode,
"effort": session.Effort,
"fastMode": session.FastMode,
"planMode": session.PlanMode,
"source": source,
"timestamp": store.Now(),
}
s.events.Publish("agent.config.changed", payload)
}
if modelChanged {
paneID := ""
if session.PaneID != nil {
paneID = *session.PaneID
}
s.events.Publish("agent.model.changed", map[string]any{
"sessionId": session.ID,
"conversationId": session.ConversationID,
"paneId": paneID,
"agentType": session.AgentType,
"model": session.Model,
"source": source,
"timestamp": store.Now(),
})
}
return session, nil
}

func (s *AgentService) AcquireLock(id, owner, turnID string, expectedVersion *int64, providerSessionID string) (domain.AgentSession, error) {
if strings.TrimSpace(owner) == "" {
return domain.AgentSession{}, errors.New("lock owner is required")
Expand Down
Empty file modified evcod/dev.command
100644 → 100755
Empty file.
9 changes: 7 additions & 2 deletions evcod/webui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,14 +507,19 @@ export class CoreApi {
});
}

launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string }) {
launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string; effort?: string; fastMode?: boolean; planMode?: boolean }) {
return this.request<{ session: AgentSession; command: string }>('/api/agent/launch', {
method: 'POST',
body: JSON.stringify(input),
});
}

updateAgentSession(id: string, patch: Partial<Pick<AgentSession, 'paneId' | 'agentType' | 'status' | 'statusReason' | 'providerSessionId'>>) {
updateAgentSession(
id: string,
patch: Partial<Pick<AgentSession, 'paneId' | 'agentType' | 'status' | 'statusReason' | 'providerSessionId' | 'model' | 'permissionMode' | 'effort' | 'fastMode' | 'planMode'>> & {
configSource?: 'web' | 'terminal' | 'launch';
},
) {
return this.request<AgentSession>(`/api/agent/sessions/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
Expand Down
64 changes: 58 additions & 6 deletions evcod/webui/src/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,23 @@ export function ConversationView(props: {
useEffect(() => {
if (session?.agentType) {
setAgentType(session.agentType);
setModel(defaultModelForAgent(session.agentType));
// Prefer the model persisted on the session so the selector reflects the
// live agent (and stays in sync after a model change) instead of resetting
// to the agent default on every session update.
setModel(session.model?.trim() ? session.model : defaultModelForAgent(session.agentType));
setPermissionMode(session.permissionMode?.trim() ? session.permissionMode : 'full-access');
setEffort(session.effort?.trim() ? session.effort : 'extra-high');
setFastMode(Boolean(session.fastMode));
setPlanMode(Boolean(session.planMode));
return;
}
const nextAgent = activeCore?.defaultAgentType ?? frontendSettings.defaultAgentType;
setAgentType(nextAgent);
setModel(defaultModelForAgent(nextAgent));
setPermissionMode('full-access');
setEffort('extra-high');
setFastMode(false);
setPlanMode(false);
}, [activeCore?.defaultAgentType, activeCore?.defaultOpenCodeModel, frontendSettings.defaultAgentType, frontendSettings.defaultOpenCodeModel, session]);

useEffect(() => {
Expand Down Expand Up @@ -338,6 +349,9 @@ export function ConversationView(props: {
agentType,
model: model.trim() ? model : undefined,
permissionMode,
effort,
fastMode,
planMode,
});
const next = launched.session;
await catchUpTimeline(conversationId);
Expand Down Expand Up @@ -558,6 +572,44 @@ export function ConversationView(props: {
setModel(defaultModelForAgent(nextAgent));
}

function handleModelChange(nextModel: string) {
setModel(nextModel);
// When a session is already running, push the change so the core records it
// and warp types the model switch into the native CLI (two-way model sync).
const live = session?.id && session.status !== 'closed' && session.status !== 'unbound';
if (props.api && live && nextModel.trim() && nextModel !== session?.model) {
props.api
.updateAgentSession(session!.id, { model: nextModel, configSource: 'web' })
.catch((error) => toastError(error, 'Could not update model'));
}
}

function updateSessionControls(patch: Partial<Pick<AgentSession, 'permissionMode' | 'effort' | 'fastMode' | 'planMode'>>) {
const live = session?.id && session.status !== 'closed' && session.status !== 'unbound';
if (!props.api || !live) return;
props.api.updateAgentSession(session!.id, { ...patch, configSource: 'web' }).catch((error) => toastError(error, 'Could not update controls'));
}

function handlePermissionModeChange(nextPermissionMode: string) {
setPermissionMode(nextPermissionMode);
if (nextPermissionMode !== session?.permissionMode) updateSessionControls({ permissionMode: nextPermissionMode });
}

function handleEffortChange(nextEffort: string) {
setEffort(nextEffort);
if (nextEffort !== session?.effort) updateSessionControls({ effort: nextEffort });
}

function handleFastModeChange(nextFastMode: boolean) {
setFastMode(nextFastMode);
if (nextFastMode !== Boolean(session?.fastMode)) updateSessionControls({ fastMode: nextFastMode });
}

function handlePlanModeChange(nextPlanMode: boolean) {
setPlanMode(nextPlanMode);
if (nextPlanMode !== Boolean(session?.planMode)) updateSessionControls({ planMode: nextPlanMode });
}

return (
<div className="conversation">
<div className="messages" ref={listRef} onScroll={onMessageScroll}>
Expand Down Expand Up @@ -617,15 +669,15 @@ export function ConversationView(props: {
agentOptions={agentOptions}
modelOptions={modelOptions}
onAgentTypeChange={changeAgentType}
onModelChange={setModel}
onModelChange={handleModelChange}
effort={effort}
onEffortChange={setEffort}
onEffortChange={handleEffortChange}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
onPermissionModeChange={handlePermissionModeChange}
fastMode={fastMode}
onFastModeChange={setFastMode}
onFastModeChange={handleFastModeChange}
planMode={planMode}
onPlanModeChange={setPlanMode}
onPlanModeChange={handlePlanModeChange}
conversationSummary={conversationSummary}
contextSummary={contextSummary}
usageSummary={usageSummary}
Expand Down
Loading