mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 17:37:37 +02:00
Wires the Phase 1 terminal.Manager into the live daemonws transport so a
browser tab on the Multica Desktop app can attach to a PTY running in
the daemon's task workdir. End-to-end frame path:
Desktop (xterm.js) → /ws/issues/{id}/terminal (server proxy, cookie
or first-frame JWT auth, workspace membership
enforced before upgrade)
→ daemonws hub (new SendToRuntime + TerminalRouter routing
terminal.* frames back to the proxy by
request_id, then re-keyed on session_id)
→ daemon (new terminal_bridge.go owns one Manager per WS
connection, drains PtySession.Output() into
terminal.data frames, surfaces ExitC as
terminal.exit; closeAll on WS disconnect)
→ terminal.Manager.OpenWithInfo (new method) — server resolves
task.work_dir/issue_id/prior_session_id from its DB and embeds
them on the protocol; daemon trusts the server payload, no
daemon-local task cache needed.
Auth + ACL:
- Browser proxy enforces workspace membership before the upgrade.
- TerminalOpenPayload carries the resolved task info; cross-workspace
is structurally impossible at the bridge layer because both
OpenParams.WorkspaceID and TaskInfo.WorkspaceID come from the same
server-resolved field.
- terminal_bridge maps terminal.{Manager.OpenWithInfo} errors to the
protocol error codes (workspace_mismatch / task_not_found /
unsupported_os / spawn_failed / internal).
Resume:
- Server passes prior_session_id; daemon injects CLAUDE_SESSION_ID +
MULTICA_{WORKSPACE,ISSUE,TASK,USER}_ID into the PTY env per the RFC.
`claude --resume \$CLAUDE_SESSION_ID` continues the agent's session.
Lifecycle:
- Daemon installs a fresh terminalBridge per daemonws connection and
tears every PtySession down on disconnect; session_ids minted on one
WS cannot be reused on a reconnect because the server-side routing
registration is also gone.
- daemonws client.send buffer raised from 16 to 256 and read limit
from 4KB to 64KB so PTY traffic fits without evicting connections
used for heartbeat / wakeup hints.
Desktop UI:
- packages/views/issues/components/terminal-panel.tsx renders an
xterm.js console with FitAddon, base64 wire encoding, ResizeObserver
→ terminal.resize, reconnect button, and a clear web-only placeholder
when window.desktopAPI is absent.
- TerminalPanelSection wrapper hangs collapsed in the issue-detail
sidebar next to the execution log so bootstrap doesn't run for
every issue view.
Tests:
- terminal/manager_test.go: OpenWithInfo happy path + cross-workspace
reject (16 tests total, all -race clean).
- daemonws/terminal_test.go: TerminalRouter request_id → session_id
re-keying and unknown-session drop.
- daemon/terminal_bridge_test.go: server-supplied workdir round-trips
through OpenWithInfo, missing-workdir surfaces task_not_found and
never spawns, data+exit round trip.
- GOOS=windows go test -c clean for both daemon and daemon/terminal.
Phase 1 / 2 / 3 / 4 mapping unchanged: Phase 3 (CLI) and Phase 4
(execenv GC hook + issue runs entry + audit log) are still untouched.
Co-authored-by: multica-agent <github@multica.ai>
267 lines
11 KiB
Go
267 lines
11 KiB
Go
package protocol
|
|
|
|
import "encoding/json"
|
|
|
|
// Message is the envelope for all WebSocket messages.
|
|
type Message struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
// TaskDispatchPayload is sent from server to daemon when a task is assigned.
|
|
type TaskDispatchPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
IssueID string `json:"issue_id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// TaskAvailablePayload is sent from server to daemon as a wakeup hint. The
|
|
// daemon still claims work through the existing HTTP claim endpoint.
|
|
type TaskAvailablePayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
}
|
|
|
|
// TaskProgressPayload is sent from daemon to server during task execution.
|
|
type TaskProgressPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
Summary string `json:"summary"`
|
|
Step int `json:"step,omitempty"`
|
|
Total int `json:"total,omitempty"`
|
|
}
|
|
|
|
// TaskCompletedPayload is sent from daemon to server when a task finishes.
|
|
type TaskCompletedPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
PRURL string `json:"pr_url,omitempty"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
// TaskMessagePayload represents a single agent execution message (tool call, text, etc.)
|
|
type TaskMessagePayload struct {
|
|
TaskID string `json:"task_id"`
|
|
IssueID string `json:"issue_id,omitempty"`
|
|
Seq int `json:"seq"`
|
|
Type string `json:"type"` // "text", "tool_use", "tool_result", "error"
|
|
Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result
|
|
Content string `json:"content,omitempty"` // text content
|
|
Input map[string]any `json:"input,omitempty"` // tool input (tool_use only)
|
|
Output string `json:"output,omitempty"` // tool output (tool_result only)
|
|
}
|
|
|
|
// DaemonRegisterPayload is sent from daemon to server on connection.
|
|
type DaemonRegisterPayload struct {
|
|
DaemonID string `json:"daemon_id"`
|
|
AgentID string `json:"agent_id"`
|
|
Runtimes []RuntimeInfo `json:"runtimes"`
|
|
}
|
|
|
|
// RuntimeInfo describes an available agent runtime on the daemon's machine.
|
|
type RuntimeInfo struct {
|
|
Type string `json:"type"`
|
|
Version string `json:"version"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ChatMessagePayload is broadcast when a new chat message is created.
|
|
type ChatMessagePayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
MessageID string `json:"message_id"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ChatDonePayload is broadcast when an agent finishes responding to a chat
|
|
// message. Carries the freshly-persisted assistant ChatMessage so the client
|
|
// can write it into the messages cache inline — avoids a refetch round-trip
|
|
// during the live-timeline → AssistantMessage handoff that previously caused
|
|
// a visible flicker (#2123).
|
|
type ChatDonePayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
TaskID string `json:"task_id"`
|
|
MessageID string `json:"message_id,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
|
|
// Fires to other devices so their unread counts stay in sync.
|
|
type ChatSessionReadPayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
}
|
|
|
|
// ChatSessionDeletedPayload is broadcast when a chat session is hard-deleted
|
|
// so other tabs/devices drop it from their session lists and reset the active
|
|
// pointer if it referenced the deleted session.
|
|
type ChatSessionDeletedPayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
}
|
|
|
|
// ChatSessionUpdatedPayload is broadcast when a user-editable field on a
|
|
// chat session changes (today: title via inline rename). Other tabs/devices
|
|
// patch the session row in their cached list so the dropdown stays in sync
|
|
// without a full refetch.
|
|
type ChatSessionUpdatedPayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
Title string `json:"title"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket
|
|
// to update last_seen_at and pull pending actions for a single runtime.
|
|
// Mirrors the body of POST /api/daemon/heartbeat so both transports share
|
|
// identical semantics.
|
|
type DaemonHeartbeatRequestPayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
|
|
// DaemonHeartbeatAckPayload is the server's reply to DaemonHeartbeatRequestPayload.
|
|
// JSON shape mirrors the HTTP heartbeat response so daemon code can decode either.
|
|
//
|
|
// RuntimeGone is the WebSocket replacement for the HTTP 404 "runtime not found"
|
|
// response. When the server discovers the runtime row was deleted (UI delete,
|
|
// 7-day offline GC), it sends back an ack with Status=HeartbeatStatusRuntimeGone
|
|
// and RuntimeGone=true rather than tearing down the connection with an error.
|
|
// The daemon reads this signal, prunes the stale runtime from its local state
|
|
// and re-registers; without it the dead UUID would keep heartbeating until the
|
|
// daemon process restarts.
|
|
type DaemonHeartbeatAckPayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Status string `json:"status"`
|
|
RuntimeGone bool `json:"runtime_gone,omitempty"`
|
|
PendingUpdate *DaemonHeartbeatPendingUpdate `json:"pending_update,omitempty"`
|
|
PendingModelList *DaemonHeartbeatPendingModelList `json:"pending_model_list,omitempty"`
|
|
PendingLocalSkills *DaemonHeartbeatPendingLocalSkills `json:"pending_local_skills,omitempty"`
|
|
PendingLocalSkillImport *DaemonHeartbeatPendingLocalSkillImport `json:"pending_local_skill_import,omitempty"`
|
|
}
|
|
|
|
// HeartbeatStatusRuntimeGone is the ack Status used when the runtime row no
|
|
// longer exists server-side. Companion to DaemonHeartbeatAckPayload.RuntimeGone.
|
|
const HeartbeatStatusRuntimeGone = "runtime_gone"
|
|
|
|
// DaemonHeartbeatPendingUpdate describes a CLI-update action the daemon
|
|
// should run for the runtime.
|
|
type DaemonHeartbeatPendingUpdate struct {
|
|
ID string `json:"id"`
|
|
TargetVersion string `json:"target_version"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingModelList describes a request for the daemon to
|
|
// enumerate the runtime's supported models.
|
|
type DaemonHeartbeatPendingModelList struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingLocalSkills describes a request for the runtime's
|
|
// local-skill inventory.
|
|
type DaemonHeartbeatPendingLocalSkills struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingLocalSkillImport describes a request to import a
|
|
// specific runtime local skill.
|
|
type DaemonHeartbeatPendingLocalSkillImport struct {
|
|
ID string `json:"id"`
|
|
SkillKey string `json:"skill_key"`
|
|
}
|
|
|
|
// Terminal WS message types. These flow over the existing daemonws hub
|
|
// between client (web/desktop/CLI) and daemon. Bytes payloads are base64
|
|
// encoded so they can travel as JSON text frames without binary framing.
|
|
const (
|
|
// TerminalOpen — client → daemon: request a new PTY session bound to a task workdir.
|
|
MessageTypeTerminalOpen = "terminal.open"
|
|
// TerminalOpened — daemon → client: ack carrying the session_id and resolved workdir.
|
|
MessageTypeTerminalOpened = "terminal.opened"
|
|
// TerminalData — bidirectional: PTY stdin (client→daemon) / stdout+stderr (daemon→client).
|
|
MessageTypeTerminalData = "terminal.data"
|
|
// TerminalResize — client → daemon: window-size change.
|
|
MessageTypeTerminalResize = "terminal.resize"
|
|
// TerminalClose — bidirectional: explicit teardown request / ack.
|
|
MessageTypeTerminalClose = "terminal.close"
|
|
// TerminalExit — daemon → client: child process exited; carries exit code and optional reason.
|
|
MessageTypeTerminalExit = "terminal.exit"
|
|
// TerminalError — daemon → client: open/resize/etc. failed; carries human-readable code+message.
|
|
MessageTypeTerminalError = "terminal.error"
|
|
)
|
|
|
|
// TerminalOpenPayload requests a PTY session bound to the given task's
|
|
// workdir. WorkspaceID is the workspace the caller is acting in; the daemon
|
|
// must reject if it does not match the task's workspace.
|
|
//
|
|
// The server resolves WorkDir / IssueID / PriorSessionID from its own DB
|
|
// (the daemon has no persistent task cache) and embeds them here before
|
|
// forwarding to the daemon. The daemon trusts these fields because the
|
|
// daemonws connection is already authenticated and scoped — but it still
|
|
// rechecks WorkspaceID against the request body to catch a misrouted frame.
|
|
type TerminalOpenPayload struct {
|
|
RequestID string `json:"request_id"`
|
|
TaskID string `json:"task_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
IssueID string `json:"issue_id,omitempty"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
PriorSessionID string `json:"prior_session_id,omitempty"`
|
|
Cols uint16 `json:"cols"`
|
|
Rows uint16 `json:"rows"`
|
|
}
|
|
|
|
// TerminalOpenedPayload echoes the request_id and carries the session_id the
|
|
// client must include on subsequent data/resize/close frames.
|
|
type TerminalOpenedPayload struct {
|
|
RequestID string `json:"request_id"`
|
|
SessionID string `json:"session_id"`
|
|
WorkDir string `json:"work_dir"`
|
|
Shell string `json:"shell"`
|
|
}
|
|
|
|
// TerminalDataPayload carries raw PTY bytes in base64.
|
|
type TerminalDataPayload struct {
|
|
SessionID string `json:"session_id"`
|
|
DataB64 string `json:"data_b64"`
|
|
}
|
|
|
|
// TerminalResizePayload updates the PTY window size.
|
|
type TerminalResizePayload struct {
|
|
SessionID string `json:"session_id"`
|
|
Cols uint16 `json:"cols"`
|
|
Rows uint16 `json:"rows"`
|
|
}
|
|
|
|
// TerminalClosePayload requests teardown. Reason is informational.
|
|
type TerminalClosePayload struct {
|
|
SessionID string `json:"session_id"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// TerminalExitPayload signals the child process exited.
|
|
type TerminalExitPayload struct {
|
|
SessionID string `json:"session_id"`
|
|
ExitCode int `json:"exit_code"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// Terminal error codes returned in TerminalErrorPayload.Code.
|
|
const (
|
|
TerminalErrorCodeWorkspaceMismatch = "workspace_mismatch"
|
|
TerminalErrorCodeTaskNotFound = "task_not_found"
|
|
TerminalErrorCodeSessionNotFound = "session_not_found"
|
|
TerminalErrorCodeUnsupportedOS = "unsupported_os"
|
|
TerminalErrorCodeSpawnFailed = "spawn_failed"
|
|
TerminalErrorCodeInternal = "internal"
|
|
)
|
|
|
|
// TerminalErrorPayload reports a failure. RequestID is set when the error
|
|
// is a response to a specific open request; SessionID is set when it
|
|
// references an already-established session.
|
|
type TerminalErrorPayload struct {
|
|
RequestID string `json:"request_id,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|