mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(ai): add remote coding agent sessions for SSH hosts (#1545)
* feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL
This commit is contained in:
1 parent
77bcbdb3d6
commit
bf817d8bd1
75 files changed
+7292
-6
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# Remote AI agents
|
||||
|
||||
The **AI Agent** host action appears on the host card and in the SSH terminal toolbar. It opens a host-scoped session tab. Enable AI globally, opt in for the user, and grant `ai.agents` (administrators by default).
|
||||
|
||||
Choose Pi, OpenCode, Claude Code, or Codex; an existing Termix AI provider; a model; and an absolute working directory. The target needs POSIX SSH, Node.js 22.19+, the selected CLI on its login-shell PATH (or an explicit executable path), and SSH remote forwarding to `127.0.0.1`. Missing dependencies fail visibly. The explicit Install button installs the selected CLI and an isolated Node.js 24 LTS runtime; starting an agent never installs software automatically.
|
||||
|
||||
Termix owns the SSH connection, session history, streaming UI, cancellation, and permission responses. The remote runner uses the CLI's native protocol: Pi RPC, Claude streaming JSON, Codex app-server, or OpenCode's loopback HTTP API. No Paseo runtime or daemon is required.
|
||||
|
||||
Model requests travel over an SSH reverse tunnel to a session-scoped proxy. The proxy authenticates a random token, checks host access and AI permissions, restricts inference paths and the selected model, and uses the current user's saved Provider key and the existing AI private-endpoint allowlist. Provider keys never go to the browser or target. OpenAI/compatible endpoints support Pi and OpenCode; Claude needs Anthropic; Codex needs **Responses**, not just Chat Completions. Selecting an OpenAI-compatible record does not prove Responses support.
|
||||
|
||||
Agent state lives in the target user's `~/.local/state/termix-agents/<session-id>` and the AI plugin's own tables (`agent_sessions`, with events and queued prompts as separate rows). Prompts, drafts and stored event text are limited to 65,535 UTF-8 bytes, the MySQL TEXT limit. Closing a tab leaves the session running; stop ends it. A server restart stops active transports; Resume reopens the native session where supported. Sessions expire after eight hours. Up to four sessions may run per user. The UI retains the most recent 2,000 events; native history remains on the target. Deleting a Termix session does not delete its target-side directory.
|
||||
|
||||
The agent runs with the SSH user's authority. Claude/OpenCode/Codex approval requests are shown in the tab; Pi's ordinary tools run without per-tool approval. Pi extension prompts are forwarded. Structured native questions currently accept JSON answers. Root SSH gives the agent root privileges; select the account and directory accordingly. No global agent config is overwritten.
|
||||
|
||||
The initial host action asks for the working directory explicitly; it does not infer one from terminal buffer text. Mobile-native UI and cross-host session migration are outside this release.
|
||||
|
||||
## Validation
|
||||
|
||||
`tests/backend/agents/agents.test.ts` covers target/command validation, inference-path restrictions, protocol compatibility, and four native-protocol fixture processes including an approval round trip. A real provider turn must also be checked separately; fixture success is not model access verification.
|
||||
|
||||
## Official runtime installation
|
||||
|
||||
The host panel provides **Install selected agent and Node.js**, with streamed progress and version verification. It uses the same host access, AI opt-in, and `ai.agents` permission as remote sessions, and records an audit event. Installation needs curl, tar, a SHA-256 utility, and Linux/macOS x64 or arm64. It requires no sudo and does not change system packages, shell profiles, existing CLI installations, or SSH forwarding policy. Stop active agents on the host before installing.
|
||||
|
||||
Node.js 24 LTS comes from https://nodejs.org/dist/ and must match the official SHA-256 manifest. Agent package names are fixed to the official documentation: [Pi](https://pi.dev/docs/latest/quickstart), [OpenCode](https://opencode.ai/docs/), [Claude Code](https://code.claude.com/docs/en/setup), and [Codex](https://developers.openai.com/cookbook/examples/codex/secure_quality_gitlab). Only https://registry.npmjs.org dependencies are accepted; missing SHA-512 lock entries are filled from matching official version metadata. npm ci checks the locked integrity values. User/global npm configuration and lifecycle scripts are disabled. Claude/OpenCode use the verified optional platform binary directly, without running download scripts. No third-party mirror or arbitrary installer URL is accepted.
|
||||
|
||||
Files live under `~/.local/share/termix-agent-runtime`. Termix automatically uses this runtime for launches with the default executable. A verified replacement retains the previous installation in `<agent>.previous`. Unsupported systems, missing prerequisites, download failures, checksum mismatches, and unavailable platform binaries fail visibly without activating the replacement. SSH remote forwarding remains a separate administrator setting.
|
||||
|
||||
## Enable Agent forwarding
|
||||
|
||||
The separate **Enable Agent forwarding** button configures standard Linux/systemd OpenSSH servers using root or non-interactive sudo. It derives the SSH account and actual peer address from the SSH connection, rather than accepting them from the browser. Existing working remote forwarding is left unchanged. Otherwise, it inserts a scoped `Match User ... Address ...` block allowing `127.0.0.1:*` remote listeners with `GatewayPorts no`; existing local forwarding is preserved. This uses the same host access and `ai.agents` gates and records an audit event.
|
||||
|
||||
The operation backs up sshd_config under `/etc/ssh/termix-agent-backups`, validates both syntax and effective rules before activation, and reloads the active SSH service. Failed activation/reload restores the previous configuration. Existing `DisableForwarding` policies are not overridden. Start/resume creates a new SSH connection after a successful change. Idempotency, preservation of local forwarding, invalid configuration rejection, and reload-failure rollback are covered by executable shell fixtures.
|
||||
|
||||
After configuration, Termix opens a fresh SSH connection and verifies an actual loopback remote listener before reporting success. Key-level forwarding restrictions or a different active server configuration therefore remain visible failures.
|
||||
|
||||
The conversation composer sends with Enter and inserts a newline with Shift+Enter. Escape interrupts a running agent when focus is inside its panel. IME composition and repeated keydown events do not submit or interrupt; a failed send preserves the draft.
|
||||
|
||||
## Conversation workflow
|
||||
|
||||
- Enter sends an idle turn or queues a message while running. A session stores up to 20 queued messages, each editable/removable until dispatched. Dispatch is FIFO after completion; cancellation, archive, and remote errors pause the queue. Continue explicitly resumes it. A stopped session must be resumed first. Messages already dispatched are not automatically replayed after a transport failure.
|
||||
- Rename and search sessions by name/model/directory. Archive stops the remote process and pauses its queue; restore preserves its transcript. Composer drafts are saved per session after 500 ms and on tab/session unmount. Unsent attachments remain reusable in the session attachment picker.
|
||||
- Upload a file, paste an image into the composer, or reference a regular file within the working directory (symlinks outside it are rejected). Limits: 1 MiB per file, four per message, 100 per session. Uploads live in the remote user's private session directory. Image MIME is detected from bytes and sent through native Pi/Claude/Codex/OpenCode image inputs; the selected provider/model must support vision. Other files are referenced by their host path for the agent's tools. No file is sent to the model until a message using it is dispatched.
|
||||
- Git review lists changed/untracked files and shows unstaged and staged diffs, including deleted files. Worktree creation makes a new branch from HEAD in the remote user's Termix state directory. It preserves uncommitted changes in the original workspace, does not overwrite an existing branch, and offers a new session in the resulting directory. Repository hooks, external diff commands, and textconv are disabled for these operations. Worktrees are retained until the user removes them explicitly.
|
||||
- Browser event streams reconnect with bounded backoff and refresh a persisted snapshot before continuing from its sequence cursor. Duplicate events are suppressed and events are written in batches at most once a second. Access-denied sessions stop retrying. A server/SSH restart still requires Resume; the UI never replays a potentially executed command. The 2,000-event UI retention limit still applies.
|
||||
|
||||
Tests cover FIFO/cancel behavior through the HTTP routes, cross-user metadata/attachment access, native image payloads for four CLI protocols, real temporary Git repositories, path/size boundaries, archive persistence, keyboard/IME handling, and dropped-stream recovery.
|
||||
@@ -116,6 +116,82 @@
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
},
|
||||
"plugin": {
|
||||
"name": "AI Assistant"
|
||||
}
|
||||
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Bekyk dokumente"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "عرض المستندات"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Преглед на документи"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Veure documents"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Zobrazit dokumenty"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Se dokumenter"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokumentation anzeigen"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Προβολή εγγράφων"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Ver documentación"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Näytä dokumentit"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Voir la documentation"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "הצג מסמכים"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "दस्तावेज़ देखें"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokumentumok megtekintése"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Visualizza documentazione"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "ドキュメントを表示"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "문서 보기"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Documenten bekijken"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Zobacz dokumentację"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Documentação"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Ver documentação"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Vizualizați documentele"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Документация"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Dokümanları görüntüle"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "View docs"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "Xem tài liệu"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,82 @@
|
||||
"hosts": {
|
||||
"docsLink": "查看文档"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "运行远程 AI Agent",
|
||||
"permissionDescription": "使用已配置的 AI Provider 在 SSH 主机上启动 Agent。Agent 可使用 SSH 账号权限执行命令和修改文件。",
|
||||
"new": "新建会话",
|
||||
"description": "在此主机的目录中运行 Agent,使用 Termix 配置的模型服务。",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix Provider",
|
||||
"chooseProvider": "选择兼容的 Provider",
|
||||
"model": "模型",
|
||||
"directory": "工作目录",
|
||||
"executable": "可执行文件(可选绝对路径)",
|
||||
"requirements": "主机需要 Node.js 22.19+ 及所选 Agent,并允许 SSH 回环地址远程转发。不覆盖已有 Agent 配置。",
|
||||
"responsesRequired": "Codex 需要 Responses API;仅支持 Chat Completions 的接口无法用于 Codex。",
|
||||
"piPermissions": "Pi 工具使用当前 SSH 账号权限执行。请只在允许 Agent 修改的目录中启动。",
|
||||
"start": "启动 Agent",
|
||||
"resume": "恢复会话",
|
||||
"stop": "停止会话",
|
||||
"interrupt": "中断任务",
|
||||
"send": "发送",
|
||||
"prompt": "让 Agent 在此目录中完成任务…",
|
||||
"noSessions": "此主机还没有 Agent 会话。",
|
||||
"approval": "Agent 需要你的决定",
|
||||
"answer": "填写答复(结构化问题使用 JSON)",
|
||||
"allow": "允许一次",
|
||||
"deny": "拒绝",
|
||||
"tool": "工具执行",
|
||||
"streamFailed": "Agent 消息连接断开,请重新打开会话连接。",
|
||||
"status": {
|
||||
"starting": "正在启动",
|
||||
"ready": "就绪",
|
||||
"running": "执行中",
|
||||
"stopped": "已停止",
|
||||
"error": "出错"
|
||||
},
|
||||
"installRuntime": "安装所选 Agent 和 Node.js",
|
||||
"installSource": "仅从官方来源安装:nodejs.org(SHA-256 校验)、registry.npmjs.org(锁定依赖与完整性校验)。安装到当前 SSH 用户的 ~/.local/share/termix-agent-runtime;需要 curl、tar 和 Linux/macOS x64 或 arm64。不修改 SSH 转发策略。",
|
||||
"installSucceeded": "安装及版本校验成功。现在可以启动 Agent;SSH 还需允许回环地址远程转发。",
|
||||
"installFailed": "安装失败,请查看日志后重试。",
|
||||
"installLog": "安装日志",
|
||||
"enableForwarding": "一键启用 Agent 转发",
|
||||
"forwardingScope": "需要 Linux、systemd/OpenSSH,以及 root 或免密 sudo。自动备份、校验并重载 SSH,仅为当前 SSH 账号与实际连接来源开放回环远程转发,保留已有本地转发权限。失败自动回滚。",
|
||||
"forwardingSucceeded": "SSH 回环远程转发已就绪。请启动或恢复 Agent,建立新的 SSH 连接。",
|
||||
"forwardingFailed": "SSH 转发配置失败,请查看日志;未通过校验的配置不会保留。",
|
||||
"search": "搜索会话",
|
||||
"showArchived": "显示已归档",
|
||||
"sessionName": "会话名称",
|
||||
"rename": "重命名",
|
||||
"archive": "归档并停止",
|
||||
"restore": "恢复归档",
|
||||
"queue": "待发消息",
|
||||
"enqueue": "加入队列",
|
||||
"continueQueue": "继续队列",
|
||||
"pauseQueue": "暂停队列",
|
||||
"editQueued": "编辑待发消息",
|
||||
"save": "保存",
|
||||
"remove": "移除",
|
||||
"upload": "上传附件",
|
||||
"remoteFile": "工作目录内的文件路径",
|
||||
"attach": "引用文件",
|
||||
"attachments": "复用会话附件",
|
||||
"pasteImage": "可在输入框粘贴图片。每条最多 4 个附件,每个不超过 1 MiB。",
|
||||
"attachmentLimit": "每条最多 4 个附件,每个不超过 1 MiB",
|
||||
"review": "Git 改动与 worktree",
|
||||
"gitStatus": "刷新 Git 状态",
|
||||
"diffFile": "要审阅的文件路径",
|
||||
"diff": "查看 diff",
|
||||
"branch": "新分支名称",
|
||||
"createWorktree": "创建 worktree",
|
||||
"useWorktree": "在此 worktree 新建会话",
|
||||
"worktreeHint": "从 HEAD 创建新分支。未提交改动保留在当前目录,不会自动删除已有 worktree。",
|
||||
"clean": "没有改动",
|
||||
"reconnecting": "正在重连并补齐消息…",
|
||||
"unavailable": "无法访问会话,请检查权限或重新登录。",
|
||||
"shortcuts": "Enter 发送或排队 · Shift+Enter 换行 · Esc 中断并暂停队列"
|
||||
},
|
||||
"plugin": {
|
||||
"name": "AI 助手"
|
||||
}
|
||||
|
||||
@@ -115,5 +115,81 @@
|
||||
},
|
||||
"hosts": {
|
||||
"docsLink": "檢視文件"
|
||||
},
|
||||
"agents": {
|
||||
"title": "AI Agent",
|
||||
"permissionTitle": "Run remote AI agents",
|
||||
"permissionDescription": "Start coding agents on SSH hosts using configured AI providers. Agents can execute commands and change files as the SSH user.",
|
||||
"new": "New session",
|
||||
"description": "Run an agent in a directory on this host using a Termix AI provider.",
|
||||
"runtime": "Agent",
|
||||
"provider": "Termix provider",
|
||||
"chooseProvider": "Choose a compatible provider",
|
||||
"model": "Model",
|
||||
"directory": "Working directory",
|
||||
"executable": "Executable (optional absolute path)",
|
||||
"requirements": "The host needs Node.js 22.19+ and the selected agent installed. SSH remote loopback forwarding must be allowed. Existing agent configurations are not overwritten.",
|
||||
"responsesRequired": "Codex requires a Responses API endpoint. A Chat Completions-only endpoint cannot run Codex.",
|
||||
"piPermissions": "Pi tools run with this SSH account’s permissions. Only launch in a directory you authorize the agent to modify.",
|
||||
"start": "Start agent",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt turn",
|
||||
"send": "Send",
|
||||
"prompt": "Ask the agent to work in this directory…",
|
||||
"noSessions": "No agent sessions for this host.",
|
||||
"approval": "Agent needs your decision",
|
||||
"answer": "Answer (JSON for structured questions)",
|
||||
"allow": "Allow once",
|
||||
"deny": "Deny",
|
||||
"tool": "Tool activity",
|
||||
"streamFailed": "Agent stream disconnected. Reopen the session to reconnect.",
|
||||
"status": {
|
||||
"starting": "Starting",
|
||||
"ready": "Ready",
|
||||
"running": "Working",
|
||||
"stopped": "Stopped",
|
||||
"error": "Error"
|
||||
},
|
||||
"installRuntime": "Install selected agent and Node.js",
|
||||
"installSource": "Official sources only: nodejs.org (SHA-256 verified) and registry.npmjs.org (locked package integrity). Installs for the SSH user in ~/.local/share/termix-agent-runtime; requires curl, tar, and Linux/macOS x64 or arm64. Does not change SSH forwarding policy.",
|
||||
"installSucceeded": "Installation verified. You can now start the agent; SSH loopback remote forwarding must also be allowed.",
|
||||
"installFailed": "Installation failed. Review the log and retry.",
|
||||
"installLog": "Installation log",
|
||||
"enableForwarding": "Enable Agent forwarding",
|
||||
"forwardingScope": "Linux with systemd/OpenSSH; requires root or passwordless sudo. Backs up and validates SSH configuration, then reloads it. Only the current SSH account and observed connection source receive loopback remote forwarding; existing local forwarding is preserved. Failed changes are rolled back.",
|
||||
"forwardingSucceeded": "SSH loopback remote forwarding is ready. Start or resume the agent to open a new SSH connection.",
|
||||
"forwardingFailed": "SSH forwarding configuration failed. Review the log; no unvalidated configuration is kept.",
|
||||
"search": "Search sessions",
|
||||
"showArchived": "Show archived",
|
||||
"sessionName": "Session name",
|
||||
"rename": "Rename",
|
||||
"archive": "Archive and stop",
|
||||
"restore": "Restore session",
|
||||
"queue": "Queued messages",
|
||||
"enqueue": "Queue message",
|
||||
"continueQueue": "Continue queue",
|
||||
"pauseQueue": "Pause queue",
|
||||
"editQueued": "Edit queued message",
|
||||
"save": "Save",
|
||||
"remove": "Remove",
|
||||
"upload": "Upload attachment",
|
||||
"remoteFile": "File path inside working directory",
|
||||
"attach": "Attach file",
|
||||
"attachments": "Reuse a session attachment",
|
||||
"pasteImage": "Paste an image in the composer. Up to 4 attachments, 1 MiB each.",
|
||||
"attachmentLimit": "Up to 4 attachments, 1 MiB each",
|
||||
"review": "Git changes and worktrees",
|
||||
"gitStatus": "Refresh Git status",
|
||||
"diffFile": "File path to review",
|
||||
"diff": "View diff",
|
||||
"branch": "New branch name",
|
||||
"createWorktree": "Create worktree",
|
||||
"useWorktree": "New session in this worktree",
|
||||
"worktreeHint": "Creates a new branch from HEAD. Uncommitted changes stay in the current directory. Existing worktrees are never deleted automatically.",
|
||||
"clean": "No changes",
|
||||
"reconnecting": "Reconnecting and restoring messages…",
|
||||
"unavailable": "Session is no longer accessible. Check your permissions or sign in again.",
|
||||
"shortcuts": "Enter sends or queues · Shift+Enter adds a line · Esc interrupts and pauses the queue"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"notify:send",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
"users:impersonate",
|
||||
"events:core"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
@@ -87,6 +88,12 @@
|
||||
"titleKey": "nav.ai",
|
||||
"icon": "Sparkles",
|
||||
"openFrom": ["rail", "palette"]
|
||||
},
|
||||
{
|
||||
"id": "ai-agents",
|
||||
"titleKey": "agents.title",
|
||||
"icon": "Bot",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -130,6 +137,12 @@
|
||||
"titleKey": "permissions.secrets.share.title",
|
||||
"descriptionKey": "permissions.secrets.share.description",
|
||||
"defaultRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"name": "agents",
|
||||
"titleKey": "agents.permissionTitle",
|
||||
"descriptionKey": "agents.permissionDescription",
|
||||
"defaultRoles": ["admin"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ai 0003: agent_sessions
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_sessions` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL UNIQUE,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`provider_id` int NOT NULL,
|
||||
`agent` varchar(32) NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`cwd` text NOT NULL,
|
||||
`executable` text NOT NULL,
|
||||
`native_id` text,
|
||||
`title` text,
|
||||
`archived` boolean NOT NULL DEFAULT false,
|
||||
`queue_paused` boolean NOT NULL DEFAULT false,
|
||||
`draft` text,
|
||||
`attachments` text NOT NULL DEFAULT ('[]'),
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_ai_agent_sessions_user` ON `p_ai_agent_sessions` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_events` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`seq` int NOT NULL,
|
||||
`kind` varchar(16) NOT NULL,
|
||||
`text` text NOT NULL,
|
||||
`request_id` text,
|
||||
`choices` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX `idx_ai_agent_events_session_seq` ON `p_ai_agent_events` (`session_id`, `seq`);
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_queue` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`prompt_id` varchar(36) NOT NULL,
|
||||
`position` int NOT NULL,
|
||||
`text` text NOT NULL,
|
||||
`attachment_ids` text NOT NULL DEFAULT ('[]'),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_ai_agent_queue_session` ON `p_ai_agent_queue` (`session_id`, `position`);
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ai 0003: agent_sessions
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL UNIQUE,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"agent" varchar(32) NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"cwd" text NOT NULL,
|
||||
"executable" text NOT NULL,
|
||||
"native_id" text,
|
||||
"title" text,
|
||||
"archived" boolean NOT NULL DEFAULT false,
|
||||
"queue_paused" boolean NOT NULL DEFAULT false,
|
||||
"draft" text,
|
||||
"attachments" text NOT NULL DEFAULT '[]',
|
||||
"updated_at" text NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_sessions_user" ON "p_ai_agent_sessions" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_events" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"request_id" text,
|
||||
"choices" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_agent_events_session_seq" ON "p_ai_agent_events" ("session_id", "seq");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_queue" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"prompt_id" varchar(36) NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"attachment_ids" text NOT NULL DEFAULT '[]',
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_queue_session" ON "p_ai_agent_queue" ("session_id", "position");
|
||||
@@ -8,6 +8,11 @@
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"syncId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
@@ -191,6 +196,173 @@
|
||||
}
|
||||
],
|
||||
"adopts": "ai_proposals"
|
||||
},
|
||||
"agent_sessions": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "varchar",
|
||||
"length": 36,
|
||||
"notNull": true,
|
||||
"unique": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"providerId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"agent": {
|
||||
"type": "varchar",
|
||||
"length": 32,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"cwd": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"executable": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"nativeId": {
|
||||
"type": "text"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"archived": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"queuePaused": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"draft": {
|
||||
"type": "text"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "[]"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_ai_agent_sessions_user",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent_events": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "varchar",
|
||||
"length": 36,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"type": "varchar",
|
||||
"length": 16,
|
||||
"notNull": true
|
||||
},
|
||||
"text": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"requestId": {
|
||||
"type": "text"
|
||||
},
|
||||
"choices": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_ai_agent_events_session_seq",
|
||||
"columns": ["sessionId", "seq"],
|
||||
"unique": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent_queue": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "varchar",
|
||||
"length": 36,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"promptId": {
|
||||
"type": "varchar",
|
||||
"length": 36,
|
||||
"notNull": true
|
||||
},
|
||||
"position": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"text": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"attachmentIds": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "[]"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_ai_agent_queue_session",
|
||||
"columns": ["sessionId", "position"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ai 0003: agent_sessions
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_sessions" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"session_id" text NOT NULL UNIQUE,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"agent" text NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"cwd" text NOT NULL,
|
||||
"executable" text NOT NULL,
|
||||
"native_id" text,
|
||||
"title" text,
|
||||
"archived" integer NOT NULL DEFAULT 0,
|
||||
"queue_paused" integer NOT NULL DEFAULT 0,
|
||||
"draft" text,
|
||||
"attachments" text NOT NULL DEFAULT '[]',
|
||||
"updated_at" text NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_sessions_user" ON "p_ai_agent_sessions" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_events" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"session_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"request_id" text,
|
||||
"choices" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_agent_events_session_seq" ON "p_ai_agent_events" ("session_id", "seq");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_queue" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"session_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"prompt_id" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"attachment_ids" text NOT NULL DEFAULT '[]',
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_queue_session" ON "p_ai_agent_queue" ("session_id", "position");
|
||||
@@ -0,0 +1,95 @@
|
||||
import { shellQuote } from "./types.js";
|
||||
|
||||
export const FORWARDING_ADMIN_SCRIPT = String.raw`set -eu
|
||||
account=$1
|
||||
peer=$2
|
||||
[ "$(uname -s)" = Linux ] || { echo 'Automatic SSH configuration currently requires Linux with systemd and OpenSSH'; exit 1; }
|
||||
sshd=/usr/sbin/sshd
|
||||
config=/etc/ssh/sshd_config
|
||||
[ -x "$sshd" ] && [ -f "$config" ] || { echo 'OpenSSH server configuration not found'; exit 1; }
|
||||
"$sshd" -t
|
||||
if systemctl is-active --quiet ssh; then service=ssh; elif systemctl is-active --quiet sshd; then service=sshd; else echo 'No active systemd SSH service found'; exit 1; fi
|
||||
lock=/etc/ssh/.termix-agent-forward.lock
|
||||
mkdir "$lock" 2>/dev/null || { echo 'Another SSH configuration operation is running'; exit 1; }
|
||||
candidate=
|
||||
backup=
|
||||
changed=0
|
||||
committed=0
|
||||
cleanup() {
|
||||
code=$?
|
||||
trap - EXIT
|
||||
if [ "$changed" = 1 ] && [ "$committed" = 0 ]; then
|
||||
echo 'Restoring previous SSH configuration'
|
||||
cp -p "$backup" "$config"
|
||||
"$sshd" -t && systemctl reload "$service" || echo "SSH rollback reload failed; restore $backup manually"
|
||||
fi
|
||||
if [ -n "$candidate" ] && [ -f "$candidate" ]; then rm "$candidate"; fi
|
||||
rmdir "$lock"
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' HUP INT TERM
|
||||
context="user=$account,addr=$peer,host=termix"
|
||||
effective=$("$sshd" -T -C "$context")
|
||||
echo "SSH account: $account; observed connection source: $peer"
|
||||
if printf '%s\n' "$effective" | grep -qx 'disableforwarding yes'; then
|
||||
echo 'DisableForwarding is enabled; an administrator must review this policy first'
|
||||
exit 1
|
||||
fi
|
||||
tcp=$(printf '%s\n' "$effective" | awk '$1 == "allowtcpforwarding" {print $2}')
|
||||
listen=$(printf '%s\n' "$effective" | awk '$1 == "permitlisten" {$1=""; print}')
|
||||
case "$tcp" in
|
||||
yes|all|remote)
|
||||
for rule in $listen; do
|
||||
case "$rule" in any|'127.0.0.1:*'|'*:*') echo 'Loopback remote forwarding is already allowed'; exit 0;; esac
|
||||
done;;
|
||||
esac
|
||||
# Preserve an already allowed local forwarding capability.
|
||||
case "$tcp" in yes|all|local) tcp=yes;; *) tcp=remote;; esac
|
||||
begin="# BEGIN Termix Agent $account $peer"
|
||||
end="# END Termix Agent $account $peer"
|
||||
block=$(printf '%s\nMatch User %s Address %s\n AllowTcpForwarding %s\n PermitListen 127.0.0.1:*\n GatewayPorts no\n%s\n' "$begin" "$account" "$peer" "$tcp" "$end")
|
||||
candidate=$(mktemp /etc/ssh/.termix-sshd.XXXXXX)
|
||||
cp -p "$config" "$candidate"
|
||||
awk -v begin="$begin" -v end="$end" -v block="$block" '
|
||||
$0 == begin { skip=1; next }
|
||||
$0 == end { skip=0; next }
|
||||
!skip {
|
||||
if (!inserted && ($0 ~ /^[ \t]*Match[ \t]/ || $0 ~ /^# BEGIN Termix Agent /)) { print block; inserted=1 }
|
||||
print
|
||||
}
|
||||
END { if (!inserted) print block }
|
||||
' "$config" > "$candidate"
|
||||
"$sshd" -t -f "$candidate"
|
||||
check=$("$sshd" -T -f "$candidate" -C "$context")
|
||||
printf '%s\n' "$check" | grep -qx "allowtcpforwarding $tcp"
|
||||
printf '%s\n' "$check" | grep -Fqx 'permitlisten 127.0.0.1:*'
|
||||
printf '%s\n' "$check" | grep -qx 'gatewayports no'
|
||||
printf '%s\n' "$check" | grep -qx 'disableforwarding no'
|
||||
mkdir -p /etc/ssh/termix-agent-backups
|
||||
chmod 700 /etc/ssh/termix-agent-backups
|
||||
backup=$(mktemp /etc/ssh/termix-agent-backups/sshd_config.XXXXXX)
|
||||
cp -p "$config" "$backup"
|
||||
echo "Backup: $backup"
|
||||
changed=1
|
||||
mv "$candidate" "$config"
|
||||
systemctl reload "$service"
|
||||
"$sshd" -t
|
||||
committed=1
|
||||
echo 'Enabled loopback remote forwarding for this SSH account and source only'
|
||||
`;
|
||||
|
||||
export function forwardingScript(): string {
|
||||
return `set -eu
|
||||
account=$(id -un)
|
||||
peer=$(printf '%s' "$SSH_CONNECTION" | cut -d ' ' -f 1)
|
||||
printf '%s' "$account" | grep -Eq '^[A-Za-z0-9_.-]+$' || { echo 'Unsupported SSH account name'; exit 1; }
|
||||
printf '%s' "$peer" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo 'Unable to determine the SSH connection source'; exit 1; }
|
||||
script=${shellQuote(FORWARDING_ADMIN_SCRIPT)}
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
exec sh -c "$script" termix "$account" "$peer"
|
||||
else
|
||||
exec sudo -n sh -c "$script" termix "$account" "$peer"
|
||||
fi
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Router } from "express";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { forwardingScript } from "./forwarding.js";
|
||||
import { AGENTS, shellQuote, type AgentKind } from "./types.js";
|
||||
|
||||
export const PACKAGES: Record<AgentKind, string> = {
|
||||
pi: "@earendil-works/pi-coding-agent",
|
||||
opencode: "opencode-ai",
|
||||
claude: "@anthropic-ai/claude-code",
|
||||
codex: "@openai/codex",
|
||||
};
|
||||
|
||||
// No caller-supplied commands, versions, registries, or download URLs.
|
||||
export function installScript(agent: AgentKind): string {
|
||||
if (!AGENTS.includes(agent)) throw Error("Unsupported agent");
|
||||
return `set -eu
|
||||
umask 077
|
||||
agent=${shellQuote(agent)}
|
||||
package=${shellQuote(PACKAGES[agent])}
|
||||
root="$HOME/.local/share/termix-agent-runtime"
|
||||
mkdir -p "$root"
|
||||
lock="$root/install.lock"
|
||||
mkdir "$lock" 2>/dev/null || { echo 'Another installation is running'; exit 1; }
|
||||
stage=$(mktemp -d "$root/staging.XXXXXX")
|
||||
trap 'rm -rf "$stage"; rmdir "$lock"' EXIT
|
||||
trap 'exit 130' HUP INT TERM
|
||||
case "$(uname -s)" in Linux) platform=linux;; Darwin) platform=darwin;; *) echo 'Only Linux and macOS are supported'; exit 1;; esac
|
||||
case "$(uname -m)" in x86_64|amd64) arch=x64;; aarch64|arm64) arch=arm64;; *) echo 'Only x64 and arm64 are supported'; exit 1;; esac
|
||||
for tool in curl tar; do command -v "$tool" >/dev/null || { echo "Missing prerequisite: $tool"; exit 1; }; done
|
||||
fetch() { curl --fail --silent --show-error --proto '=https' --tlsv1.2 --max-redirs 0 --connect-timeout 20 --max-time 300 "$1" -o "$2"; }
|
||||
echo "Detected $platform / $arch; installing for $(id -un)"
|
||||
if [ ! -x "$root/node/bin/node" ]; then
|
||||
echo 'Downloading Node.js 24 LTS from https://nodejs.org'
|
||||
fetch https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt "$stage/SHASUMS256.txt"
|
||||
artifact=$(awk -v suffix="-$platform-$arch.tar.gz" '$2 ~ (suffix "$") {print $2}' "$stage/SHASUMS256.txt")
|
||||
printf '%s' "$artifact" | grep -Eq '^node-v24\\.[0-9]+\\.[0-9]+-(linux|darwin)-(x64|arm64)\\.tar\\.gz$' || { echo 'Invalid official Node.js manifest'; exit 1; }
|
||||
fetch "https://nodejs.org/dist/latest-v24.x/$artifact" "$stage/$artifact"
|
||||
expected=$(awk -v file="$artifact" '$2 == file {print $1}' "$stage/SHASUMS256.txt")
|
||||
if command -v sha256sum >/dev/null; then actual=$(sha256sum "$stage/$artifact" | awk '{print $1}'); else actual=$(shasum -a 256 "$stage/$artifact" | awk '{print $1}'); fi
|
||||
[ "$actual" = "$expected" ] || { echo 'Node.js SHA-256 verification failed'; exit 1; }
|
||||
mkdir "$stage/node"
|
||||
tar -xzf "$stage/$artifact" -C "$stage/node" --strip-components=1
|
||||
"$stage/node/bin/node" --version
|
||||
mv "$stage/node" "$root/node"
|
||||
echo 'Node.js SHA-256 verified'
|
||||
fi
|
||||
PATH="$root/node/bin:$PATH"
|
||||
export PATH
|
||||
touch "$stage/user.npmrc" "$stage/global.npmrc"
|
||||
mkdir "$stage/package"
|
||||
printf '{"name":"termix-agent-runtime","version":"1.0.0","private":true}' > "$stage/package/package.json"
|
||||
# Ignore host npmrc, scoped registry overrides and lifecycle download scripts.
|
||||
npm_official() { env -i HOME="$HOME" PATH="$PATH" npm_config_userconfig="$stage/user.npmrc" npm_config_globalconfig="$stage/global.npmrc" npm_config_registry=https://registry.npmjs.org npm_config_strict_ssl=true npm_config_update_notifier=false npm_config_cache="$root/npm-cache" npm "$@"; }
|
||||
cd "$stage/package"
|
||||
echo "Resolving $package from https://registry.npmjs.org"
|
||||
npm_official install --package-lock-only --ignore-scripts --no-audit --no-fund --save-exact "$package@latest"
|
||||
node - <<'VERIFY'
|
||||
const fs=require('fs');
|
||||
const lock=JSON.parse(fs.readFileSync('package-lock.json','utf8'));
|
||||
(async () => {
|
||||
for(const [name,p] of Object.entries(lock.packages)) {
|
||||
if(!name) continue;
|
||||
const u=new URL(p.resolved);
|
||||
if(u.origin!=='https://registry.npmjs.org' || u.username || u.password) throw Error('Non-official dependency: '+name);
|
||||
if(!/^sha512-/.test(p.integrity||'')) {
|
||||
const packageName=p.name || name.split('node_modules/').pop();
|
||||
const response=await fetch('https://registry.npmjs.org/'+encodeURIComponent(packageName)+'/'+encodeURIComponent(p.version),{redirect:'error',signal:AbortSignal.timeout(30000)});
|
||||
if(!response.ok) throw Error('Unable to verify dependency: '+name);
|
||||
const metadata=await response.json();
|
||||
if(metadata.dist?.tarball!==p.resolved || !/^sha512-/.test(metadata.dist?.integrity||'')) throw Error('Unverified dependency: '+name);
|
||||
p.integrity=metadata.dist.integrity;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('package-lock.json',JSON.stringify(lock,null,2));
|
||||
console.log('All package sources and integrity hashes verified');
|
||||
})().catch(error=>{console.error(error.message);process.exitCode=1;});
|
||||
VERIFY
|
||||
npm_official ci --ignore-scripts --no-audit --no-fund
|
||||
# Link verified optional platform binaries without running download-capable postinstall scripts.
|
||||
case "$agent" in
|
||||
claude) relative="@anthropic-ai/claude-code-$platform-$arch/claude";;
|
||||
opencode)
|
||||
variant="opencode-$platform-$arch"
|
||||
if [ "$arch" = x64 ]; then variant="$variant-baseline"; fi
|
||||
relative="$variant/bin/opencode";;
|
||||
*) relative=;;
|
||||
esac
|
||||
if [ -n "$relative" ]; then
|
||||
binary="$stage/package/node_modules/$relative"
|
||||
[ -x "$binary" ] || { echo 'Official platform binary is unavailable'; exit 1; }
|
||||
mkdir -p "$stage/package/node_modules/.bin"
|
||||
# Relative links remain valid when the verified package directory is activated.
|
||||
ln -sf "../$relative" "$stage/package/node_modules/.bin/$agent"
|
||||
fi
|
||||
"$stage/package/node_modules/.bin/$agent" --version
|
||||
mkdir -p "$root/agents"
|
||||
old="$root/agents/$agent.previous"
|
||||
rm -rf "$old"
|
||||
if [ -d "$root/agents/$agent" ]; then mv "$root/agents/$agent" "$old"; fi
|
||||
if ! mv "$stage/package" "$root/agents/$agent"; then
|
||||
if [ -d "$old" ]; then mv "$old" "$root/agents/$agent"; fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Installed $agent from the official npm registry"
|
||||
echo "Executable: $root/agents/$agent/node_modules/.bin/$agent"
|
||||
`;
|
||||
}
|
||||
|
||||
export function registerInstallRoute(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
isRunning: (hostId: number) => boolean,
|
||||
) {
|
||||
const active = new Set<number>();
|
||||
for (const operation of ["install", "forwarding"] as const)
|
||||
router.post(`/agents/${operation}`, async (req, res) => {
|
||||
const { hostId, agent } = req.body ?? {};
|
||||
if (
|
||||
!Number.isSafeInteger(hostId) ||
|
||||
hostId < 1 ||
|
||||
(operation === "install" && !AGENTS.includes(agent))
|
||||
) {
|
||||
res.status(400).json({ error: "Choose a host and supported agent" });
|
||||
return;
|
||||
}
|
||||
let dispose: (() => void) | undefined;
|
||||
let channel: ClientChannel | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let acquired = false;
|
||||
const write = (data: object) => {
|
||||
if (!res.destroyed) res.write(JSON.stringify(data) + "\n");
|
||||
};
|
||||
try {
|
||||
if (!(await ctx.hosts.checkAccess(hostId, "connect")).hasAccess)
|
||||
throw Error("SSH access is required");
|
||||
if (isRunning(hostId))
|
||||
throw Error(
|
||||
"Stop active agents on this host before changing its runtime or SSH policy",
|
||||
);
|
||||
if (active.has(hostId))
|
||||
throw Error(
|
||||
"Another agent setup operation is already running on this host",
|
||||
);
|
||||
active.add(hostId);
|
||||
acquired = true;
|
||||
const connection = await ctx.ssh.connect<Client>(hostId, {
|
||||
purpose: `ai-agent-${operation}`,
|
||||
profile: "session",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
dispose = connection.dispose;
|
||||
if (res.destroyed) throw Error("Agent setup request disconnected");
|
||||
channel = await new Promise<ClientChannel>((resolve, reject) =>
|
||||
connection.client.exec(
|
||||
"sh -lc " +
|
||||
shellQuote(
|
||||
operation === "install"
|
||||
? installScript(agent)
|
||||
: forwardingScript(),
|
||||
),
|
||||
(err, ch) => (err ? reject(err) : resolve(ch)),
|
||||
),
|
||||
);
|
||||
if (res.destroyed) throw Error("Agent setup request disconnected");
|
||||
res.set({
|
||||
"Content-Type": "application/x-ndjson",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders();
|
||||
res.once("close", () => {
|
||||
channel?.signal("TERM");
|
||||
dispose?.();
|
||||
});
|
||||
let bytes = 0;
|
||||
const log = (data: Buffer) => {
|
||||
bytes += data.length;
|
||||
if (bytes <= 256 * 1024) write({ log: data.toString() });
|
||||
};
|
||||
channel.on("data", log);
|
||||
channel.stderr.on("data", log);
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(Error("Agent setup timed out after 10 minutes")),
|
||||
600000,
|
||||
);
|
||||
channel!.once("error", reject);
|
||||
channel!.once("close", (code: number) => resolve(code ?? -1));
|
||||
});
|
||||
if (operation === "forwarding" && exitCode === 0) {
|
||||
const verification = await ctx.ssh.connect<Client>(hostId, {
|
||||
purpose: "ai-agent-forwarding-verify",
|
||||
profile: "session",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deadline = setTimeout(
|
||||
() => reject(Error("SSH forwarding verification timed out")),
|
||||
15000,
|
||||
);
|
||||
verification.client.forwardIn("127.0.0.1", 0, (error) => {
|
||||
clearTimeout(deadline);
|
||||
return error
|
||||
? reject(
|
||||
Error(
|
||||
"SSH configuration was checked, but a new connection still refused remote forwarding. Check authorized_keys restrictions and the active SSH server configuration.",
|
||||
),
|
||||
)
|
||||
: resolve();
|
||||
});
|
||||
});
|
||||
write({
|
||||
log: "Verified loopback remote forwarding on a new SSH connection\n",
|
||||
});
|
||||
} finally {
|
||||
verification.dispose();
|
||||
}
|
||||
}
|
||||
await ctx.audit.record({
|
||||
action: `agent_${operation}`,
|
||||
resourceId: String(hostId),
|
||||
success: exitCode === 0,
|
||||
details: JSON.stringify({
|
||||
agent: operation === "install" ? agent : undefined,
|
||||
package:
|
||||
operation === "install"
|
||||
? PACKAGES[agent as AgentKind]
|
||||
: undefined,
|
||||
exitCode,
|
||||
}),
|
||||
});
|
||||
write({ done: true, success: exitCode === 0 });
|
||||
res.end();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Agent setup failed";
|
||||
if (res.headersSent) {
|
||||
write({ done: true, success: false, error: message });
|
||||
res.end();
|
||||
} else res.status(400).json({ error: message });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
channel?.signal("TERM");
|
||||
dispose?.();
|
||||
if (acquired) active.delete(hostId);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/** Runs on the selected SSH host. Only a session-scoped proxy token reaches it. */
|
||||
export const REMOTE_RUNNER = String.raw`
|
||||
const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
|
||||
const {spawn} = require('node:child_process'), readline = require('node:readline');
|
||||
let config, child, nativeId, turnId, serverUrl, serverAuth, ready = false, running = false, rpcId = 0;
|
||||
const pending = new Map(), approvals = new Map();
|
||||
const emit = (kind, text, extra = {}) => process.stdout.write(JSON.stringify({kind,text,...extra})+'\n');
|
||||
const send = value => child.stdin.write(JSON.stringify(value)+'\n');
|
||||
const request = (method, params) => new Promise((resolve,reject) => {
|
||||
const id=++rpcId, timer=setTimeout(()=>{pending.delete(id);reject(Error('Agent request timed out: '+method));},60000);
|
||||
pending.set(id,{resolve,reject,timer}); send({id,method,params});
|
||||
});
|
||||
function fail(error) { emit('error',error.message || String(error)); if(!ready) { if(child?.pid) {try{process.kill(-child.pid,'SIGTERM');}catch{}} process.stdin.destroy(); } }
|
||||
function done(initial=false) { if(!initial && !running) return; running=false; emit('status','ready'); }
|
||||
async function launch(args, env) {
|
||||
const managed=path.join(os.homedir(),'.local','share','termix-agent-runtime','agents',config.agent,'node_modules','.bin',config.agent);
|
||||
const executable=(!config.executable || config.executable===config.agent) && fs.existsSync(managed)?managed:(config.executable || config.agent);
|
||||
child=spawn(executable,args,{cwd:config.cwd,env:{...process.env,...env},stdio:['pipe','pipe','pipe'],detached:true});
|
||||
child.on('error',fail);
|
||||
child.on('exit',(code)=>{emit('status','stopped');process.exitCode=code||0;process.stdin.destroy();});
|
||||
child.stderr.on('data',d=>emit('tool',d.toString().slice(0,8000)));
|
||||
readline.createInterface({input:child.stdout}).on('line',line=>{
|
||||
try { receive(JSON.parse(line)); } catch { /* CLI startup chatter is not protocol. */ }
|
||||
});
|
||||
await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject);});
|
||||
}
|
||||
function receive(m) {
|
||||
if(config.agent==='pi') {
|
||||
if(m.type==='response' && pending.has(m.id)) {const p=pending.get(m.id);pending.delete(m.id);clearTimeout(p.timer);m.success?p.resolve(m.data):p.reject(Error(m.error));return;}
|
||||
if(m.type==='assistant_message_event' || m.type==='message_update') {
|
||||
const e=m.assistantMessageEvent || m.event;
|
||||
if(e?.type==='text_delta') emit('text',e.delta);
|
||||
}
|
||||
if(m.type==='tool_execution_start') emit('tool',m.toolName+' '+JSON.stringify(m.args));
|
||||
if(m.type==='tool_execution_end') emit('tool',JSON.stringify(m.result));
|
||||
if(m.type==='agent_end') done();
|
||||
if(m.type==='response' && m.success===false) {fail(Error(m.error || 'Pi request failed'));done();}
|
||||
if(m.type==='extension_ui_request' && ['select','confirm','input','editor'].includes(m.method)) {
|
||||
approvals.set(m.id,m); emit('permission',m.title || m.message || m.method,{requestId:m.id,choices:m.options});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(config.agent==='claude') {
|
||||
if(m.type==='system' && m.session_id) {nativeId=m.session_id;emit('native','',{nativeId});}
|
||||
if(m.type==='stream_event' && m.event?.type==='content_block_delta' && m.event.delta?.type==='text_delta') emit('text',m.event.delta.text);
|
||||
if(m.type==='stream_event' && m.event?.type==='content_block_start' && m.event.content_block?.type==='tool_use') emit('tool',m.event.content_block.name);
|
||||
if(m.type==='control_request') {
|
||||
if(m.request?.subtype==='can_use_tool') {
|
||||
approvals.set(m.request_id,m);emit('permission',m.request.tool_name+' '+JSON.stringify(m.request.input),{requestId:m.request_id});
|
||||
} else send({type:'control_response',response:{subtype:'error',request_id:m.request_id,error:'Unsupported control request'}});
|
||||
}
|
||||
if(m.type==='result') {if(m.is_error) fail(Error(m.result || m.errors?.join('\n') || 'Claude turn failed'));done();}
|
||||
return;
|
||||
}
|
||||
if(config.agent==='codex') {
|
||||
if(m.id!==undefined && !m.method && pending.has(m.id)) {
|
||||
const p=pending.get(m.id);pending.delete(m.id);clearTimeout(p.timer);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);return;
|
||||
}
|
||||
if(m.id!==undefined && m.method) {
|
||||
approvals.set(String(m.id),m);emit('permission',m.method+' '+JSON.stringify(m.params),{requestId:String(m.id)});return;
|
||||
}
|
||||
if(m.method==='item/agentMessage/delta') emit('text',m.params.delta);
|
||||
if(m.method==='item/started' && m.params.item?.type!=='agentMessage') emit('tool',JSON.stringify(m.params.item));
|
||||
if(m.method==='turn/started') turnId=m.params.turn.id;
|
||||
if(m.method==='turn/completed') {if(m.params.turn.status==='failed') fail(Error(m.params.turn.error?.message || 'Codex turn failed'));done();}
|
||||
return;
|
||||
}
|
||||
}
|
||||
async function oc(method, route, body) {
|
||||
const r=await fetch(serverUrl+route,{method,headers:{'Content-Type':'application/json','x-opencode-directory':config.cwd,authorization:serverAuth},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(5000)});
|
||||
if(!r.ok) throw Error('OpenCode '+route+' returned '+r.status);
|
||||
return r.status===204?null:r.json();
|
||||
}
|
||||
async function ocEvents() {
|
||||
const response=await fetch(serverUrl+'/event',{headers:{'x-opencode-directory':config.cwd,authorization:serverAuth}});
|
||||
if(!response.ok || !response.body) throw Error('OpenCode event stream unavailable');
|
||||
let buffer='';const decoder=new TextDecoder(),partTypes=new Map();
|
||||
for await(const chunk of response.body) {
|
||||
buffer+=decoder.decode(chunk,{stream:true});
|
||||
let end;
|
||||
while((end=buffer.indexOf('\n\n'))>=0) {
|
||||
const frame=buffer.slice(0,end);buffer=buffer.slice(end+2);
|
||||
const data=frame.split('\n').filter(x=>x.startsWith('data:')).map(x=>x.slice(5).trim()).join('\n');
|
||||
if(!data) continue;
|
||||
let e;try{e=JSON.parse(data);}catch{continue;}const p=e.properties||{};
|
||||
const sessionId=p.sessionID || p.part?.sessionID || p.info?.sessionID;
|
||||
if(sessionId!==nativeId) continue;
|
||||
if(e.type==='message.part.updated') partTypes.set(p.part.id,p.part.type);
|
||||
if(e.type==='message.part.delta' && p.field==='text' && partTypes.get(p.partID)==='text') emit('text',p.delta);
|
||||
if(e.type==='message.part.updated' && p.part?.type==='tool') emit('tool',JSON.stringify(p.part));
|
||||
if(e.type==='session.idle' || (e.type==='session.status' && p.status?.type==='idle')) done();
|
||||
if(e.type==='session.error') {fail(Error(JSON.stringify(p.error)));done();}
|
||||
if(e.type==='permission.asked' || e.type==='question.asked') {approvals.set(p.id,{...p,event:e.type});emit('permission',JSON.stringify(p),{requestId:p.id});}
|
||||
}
|
||||
}
|
||||
throw Error('OpenCode event stream closed');
|
||||
}
|
||||
async function start(c) {
|
||||
config=c;
|
||||
emit('tool','Starting '+c.agent+' in '+c.cwd);
|
||||
if(!fs.statSync(c.cwd).isDirectory()) throw Error('Working directory does not exist');
|
||||
const dir=path.join(os.homedir(),'.local','state','termix-agents',c.id);
|
||||
fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const base=c.proxyUrl, token=c.token;
|
||||
if(c.agent==='pi') {
|
||||
const agentDir=path.join(dir,'config');
|
||||
fs.mkdirSync(agentDir,{recursive:true,mode:0o700});
|
||||
fs.writeFileSync(path.join(agentDir,'models.json'),JSON.stringify({providers:{termix:{baseUrl:base+'/v1',api:c.providerType==='anthropic'?'anthropic-messages':'openai-completions',apiKey:token,models:[{id:c.model,name:c.model,reasoning:false,input:['text','image'],contextWindow:131072,maxTokens:8192,cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}]}}}),{mode:0o600});
|
||||
await launch(['--mode','rpc','--provider','termix','--model',c.model,'--session',path.join(dir,'session.jsonl')],{PI_CODING_AGENT_DIR:agentDir});
|
||||
const state=await new Promise((resolve,reject)=>{const id=String(++rpcId),timer=setTimeout(()=>{pending.delete(id);reject(Error('Pi startup timed out'));},60000);pending.set(id,{resolve,reject,timer});send({id,type:'get_state'});});
|
||||
nativeId=state.sessionId;emit('native','',{nativeId});
|
||||
emit('tool','Loaded '+state.messageCount+' messages from the Pi session');
|
||||
}
|
||||
if(c.agent==='claude') {
|
||||
const args=['--print','--verbose','--input-format','stream-json','--output-format','stream-json','--include-partial-messages','--permission-prompt-tool','stdio','--model',c.model];
|
||||
if(c.nativeId) args.push('--resume',c.nativeId);
|
||||
await launch(args,{ANTHROPIC_BASE_URL:base,ANTHROPIC_API_KEY:token,ANTHROPIC_AUTH_TOKEN:'',CLAUDE_CONFIG_DIR:dir,DISABLE_UPDATES:'1',DISABLE_AUTOUPDATER:'1'});
|
||||
}
|
||||
if(c.agent==='codex') {
|
||||
await launch(['app-server','-c','model_provider="termix"','-c','model_providers.termix.name="Termix"','-c','model_providers.termix.base_url='+JSON.stringify(base+'/v1'),'-c','model_providers.termix.env_key="TERMIX_AGENT_TOKEN"','-c','model_providers.termix.wire_api="responses"'],{CODEX_HOME:dir,TERMIX_AGENT_TOKEN:token});
|
||||
await request('initialize',{clientInfo:{name:'termix',version:'1.0.0'}});send({method:'initialized'});
|
||||
const result=await request(c.nativeId?'thread/resume':'thread/start',{...(c.nativeId?{threadId:c.nativeId}:{}),cwd:c.cwd,model:c.model,approvalPolicy:'untrusted',sandbox:'workspace-write'});
|
||||
nativeId=result.thread.id;emit('native','',{nativeId});
|
||||
}
|
||||
if(c.agent==='opencode') {
|
||||
const net=require('node:net');const port=await new Promise(resolve=>{const s=net.createServer();s.listen(0,'127.0.0.1',()=>{const p=s.address().port;s.close(()=>resolve(p));});});
|
||||
serverUrl='http://127.0.0.1:'+port;
|
||||
// Loopback is shared with every local account; the password keeps them off the agent.
|
||||
const password=require('node:crypto').randomBytes(32).toString('hex');serverAuth='Basic '+Buffer.from('opencode:'+password).toString('base64');
|
||||
const settings={autoupdate:false,model:'termix/'+c.model,provider:{termix:{npm:c.providerType==='anthropic'?'@ai-sdk/anthropic':'@ai-sdk/openai-compatible',name:'Termix',options:{baseURL:base+'/v1',apiKey:token},models:{[c.model]:{name:c.model}}}},permission:'ask'};
|
||||
emit('tool','Starting OpenCode server');
|
||||
await launch(['serve','--hostname','127.0.0.1','--port',String(port)],{OPENCODE_CONFIG_CONTENT:JSON.stringify(settings),OPENCODE_SERVER_USERNAME:'opencode',OPENCODE_SERVER_PASSWORD:password,XDG_DATA_HOME:dir,XDG_CONFIG_HOME:dir});
|
||||
emit('tool','Waiting for OpenCode health');
|
||||
let available=false;for(let i=0;i<100;i++){try{await oc('GET','/global/health');available=true;break;}catch{await new Promise(r=>setTimeout(r,200));}}
|
||||
if(!available) throw Error('OpenCode startup timed out');
|
||||
emit('tool','Opening OpenCode session');
|
||||
nativeId=c.nativeId || (await oc('POST','/session',{})).id;emit('native','',{nativeId});
|
||||
void ocEvents().catch(fail);
|
||||
}
|
||||
ready=true;done(true);
|
||||
}
|
||||
async function handle(m) {
|
||||
if(m.type==='start') return start(m.config);
|
||||
if(m.type==='stop') {if(child?.pid) {try{process.kill(-child.pid,'SIGTERM');}catch{}};setTimeout(()=>process.exit(0),1500).unref();return;}
|
||||
if(!ready) throw Error('Agent is not ready');
|
||||
if(m.type==='prompt') {
|
||||
running=true;
|
||||
emit('status','running');
|
||||
try {
|
||||
const attachments=m.attachments||[],images=[];
|
||||
let text=m.text;
|
||||
for(const a of attachments) {
|
||||
if(a.mime.startsWith('image/')) {
|
||||
const fd=fs.openSync(a.path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat=fs.fstatSync(fd);if(!stat.isFile() || stat.size>1024*1024) throw Error('Image exceeds 1 MiB or is no longer a regular file');
|
||||
const data=Buffer.alloc(stat.size),size=fs.readSync(fd,data,0,data.length,0);
|
||||
images.push({type:'image',data:data.subarray(0,size).toString('base64'),mimeType:a.mime});
|
||||
} finally {fs.closeSync(fd);}
|
||||
} else text+='\nAttached file on this host: '+JSON.stringify(a.path);
|
||||
}
|
||||
if(config.agent==='pi') send({type:'prompt',message:text,images});
|
||||
if(config.agent==='claude') send({type:'user',message:{role:'user',content:[{type:'text',text:text||'Please inspect the attached images.'},...images.map(i=>({type:'image',source:{type:'base64',media_type:i.mimeType,data:i.data}}))]},parent_tool_use_id:null,session_id:nativeId||''});
|
||||
if(config.agent==='codex') await request('turn/start',{threadId:nativeId,input:[{type:'text',text},...images.map(i=>({type:'image',url:'data:'+i.mimeType+';base64,'+i.data}))]});
|
||||
if(config.agent==='opencode') await oc('POST','/session/'+nativeId+'/prompt_async',{model:{providerID:'termix',modelID:config.model},parts:[{type:'text',text},...images.map(i=>({type:'file',mime:i.mimeType,url:'data:'+i.mimeType+';base64,'+i.data}))]});
|
||||
} catch(error) {fail(error);done();}
|
||||
}
|
||||
if(m.type==='cancel') {
|
||||
if(config.agent==='pi') send({type:'abort'});
|
||||
if(config.agent==='claude') send({type:'control_request',request_id:String(++rpcId),request:{subtype:'interrupt'}});
|
||||
if(config.agent==='codex' && turnId) await request('turn/interrupt',{threadId:nativeId,turnId});
|
||||
if(config.agent==='opencode') await oc('POST','/session/'+nativeId+'/abort',{});
|
||||
}
|
||||
if(m.type==='answer') {
|
||||
const a=approvals.get(m.requestId);if(!a) throw Error('Approval request expired');
|
||||
if(config.agent==='pi') send({type:'extension_ui_response',id:m.requestId,...(a.method==='confirm'?{confirmed:m.allow}:{value:m.value,cancelled:!m.allow})});
|
||||
if(config.agent==='claude') send({type:'control_response',response:{subtype:'success',request_id:m.requestId,response:m.allow?{behavior:'allow',updatedInput:a.request.input}:{behavior:'deny',message:'Denied in Termix'}}});
|
||||
if(config.agent==='codex') {
|
||||
let result={decision:m.allow?'accept':'decline'};
|
||||
if(a.method==='item/tool/requestUserInput') {if(!m.value) throw Error('Provide JSON answers for this question');result={answers:JSON.parse(m.value)};}
|
||||
else if(!a.method.endsWith('/requestApproval')) throw Error('Unsupported Codex request; stop the session');
|
||||
send({id:a.id,result});
|
||||
}
|
||||
if(config.agent==='opencode') {
|
||||
if(a.event==='question.asked') await oc('POST','/question/'+m.requestId+(m.allow?'/reply':'/reject'),m.allow?{answers:JSON.parse(m.value||'[]')}:{});
|
||||
else await oc('POST','/permission/'+m.requestId+'/reply',{reply:m.allow?'once':'reject'});
|
||||
}
|
||||
approvals.delete(m.requestId);
|
||||
}
|
||||
}
|
||||
readline.createInterface({input:process.stdin}).on('line',line=>{try{Promise.resolve(handle(JSON.parse(line))).catch(fail);}catch(e){fail(e);}}).on('close',()=>{if(child?.pid) {try{process.kill(-child.pid,'SIGTERM');}catch{}};});
|
||||
`;
|
||||
@@ -0,0 +1,709 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createAiGate,
|
||||
readPrivateAllowlist,
|
||||
resolveAiAccess,
|
||||
} from "../gating.js";
|
||||
import { createProviderFetch } from "../providers/http.js";
|
||||
import type { AiRepository } from "../repository.js";
|
||||
import {
|
||||
AGENTS,
|
||||
clipText,
|
||||
compatible,
|
||||
shellQuote,
|
||||
validStart,
|
||||
type AgentEvent,
|
||||
type AgentSession,
|
||||
type QueuedPrompt,
|
||||
type AgentAttachment,
|
||||
} from "./types.js";
|
||||
import { registerInstallRoute } from "./install.js";
|
||||
import { workspaceOperation } from "./workspace.js";
|
||||
import { promptInput, updateSession, updateQueue } from "./session-state.js";
|
||||
import { REMOTE_RUNNER } from "./remote-runner.js";
|
||||
import { EVENT_HISTORY, type AgentStore } from "./store.js";
|
||||
|
||||
interface Live {
|
||||
session: AgentSession;
|
||||
channel?: ClientChannel;
|
||||
dispose: () => void;
|
||||
subscribers: Set<Response>;
|
||||
approvals: Set<string>;
|
||||
/** Events not yet written; flushed together at most once a second. */
|
||||
pending: AgentEvent[];
|
||||
saved: Promise<void>;
|
||||
stopping?: Promise<void>;
|
||||
draining?: boolean;
|
||||
flush?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
const MAX_BODY = 16 * 1024 * 1024;
|
||||
const PROVIDER_PATHS = new Set([
|
||||
"/chat/completions",
|
||||
"/responses",
|
||||
"/messages",
|
||||
"/messages/count_tokens",
|
||||
"/models",
|
||||
]);
|
||||
export function providerPath(raw: string): string | null {
|
||||
const url = new URL(raw, "http://localhost");
|
||||
const path = url.pathname.replace(/^\/v1(?=\/)/, "");
|
||||
return PROVIDER_PATHS.has(path) ? path : null;
|
||||
}
|
||||
|
||||
export function registerAgentRoutes(
|
||||
router: Router,
|
||||
repository: AiRepository,
|
||||
ctx: PluginContext,
|
||||
store: AgentStore,
|
||||
): void {
|
||||
const live = new Map<string, Live>();
|
||||
const actor = () => ctx.currentActor() as string;
|
||||
/** Writes are queued per session, so rows land in the order they happened. */
|
||||
const write = (entry: Live, full: boolean) => {
|
||||
entry.saved = entry.saved
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const session = { ...entry.session };
|
||||
await store.append(session, entry.pending.splice(0));
|
||||
if (full) await store.save(session);
|
||||
});
|
||||
return entry.saved;
|
||||
};
|
||||
const save = (entry: Live) => write(entry, true);
|
||||
const logFailure = (error: unknown) =>
|
||||
ctx.log.error(
|
||||
"Agent session could not be saved",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
const event = (entry: Live, input: Omit<AgentEvent, "seq">) => {
|
||||
const s = entry.session;
|
||||
const item = {
|
||||
...input,
|
||||
seq: (s.events.at(-1)?.seq ?? 0) + 1,
|
||||
text: clipText(input.text.slice(0, 32000)),
|
||||
};
|
||||
s.events.push(item);
|
||||
if (s.events.length > EVENT_HISTORY) s.events.shift();
|
||||
entry.pending.push(item);
|
||||
s.updatedAt = new Date().toISOString();
|
||||
for (const res of entry.subscribers) {
|
||||
if (!res.write(`id: ${item.seq}\ndata: ${JSON.stringify(item)}\n\n`))
|
||||
res.end();
|
||||
}
|
||||
if (!entry.flush)
|
||||
entry.flush = setTimeout(() => {
|
||||
entry.flush = undefined;
|
||||
write(entry, false).catch(logFailure);
|
||||
}, 1000);
|
||||
};
|
||||
async function allowed(s: AgentSession) {
|
||||
return (
|
||||
(await resolveAiAccess(ctx.settings, s.userId)).enabled &&
|
||||
(await ctx.rbac.hasFor(s.userId, "agents")) &&
|
||||
(await ctx.hosts.checkAccess(s.hostId, "connect")).hasAccess
|
||||
);
|
||||
}
|
||||
async function find(id: string): Promise<AgentSession> {
|
||||
if (!/^[a-f0-9-]{36}$/.test(id)) throw Error("Invalid session ID");
|
||||
const s = live.get(id)?.session ?? (await store.get(actor(), id));
|
||||
if (!s || s.userId !== actor() || !(await allowed(s)))
|
||||
throw Error("Agent session is not accessible");
|
||||
return live.has(id) ? s : { ...s, status: "stopped" };
|
||||
}
|
||||
function stop(entry: Live): Promise<void> {
|
||||
if (entry.stopping) return entry.stopping;
|
||||
if (live.get(entry.session.id) !== entry) return Promise.resolve();
|
||||
entry.stopping = (async () => {
|
||||
if (entry.channel && !entry.channel.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 2000);
|
||||
entry.channel!.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
entry.channel!.write(JSON.stringify({ type: "stop" }) + "\n");
|
||||
});
|
||||
}
|
||||
if (live.get(entry.session.id) === entry) live.delete(entry.session.id);
|
||||
entry.dispose();
|
||||
entry.session.status = "stopped";
|
||||
event(entry, { kind: "status", text: "stopped" });
|
||||
// The save below writes everything pending.
|
||||
clearTimeout(entry.flush);
|
||||
for (const res of entry.subscribers) res.end();
|
||||
await save(entry);
|
||||
})();
|
||||
return entry.stopping;
|
||||
}
|
||||
ctx.disposables.add(() => {
|
||||
for (const entry of live.values()) stop(entry);
|
||||
});
|
||||
ctx.events.on("user.deleted", (payload) => {
|
||||
const id = (payload as { userId?: string }).userId;
|
||||
for (const entry of live.values())
|
||||
if (entry.session.userId === id) stop(entry);
|
||||
});
|
||||
|
||||
async function persist(s: AgentSession) {
|
||||
const entry = live.get(s.id);
|
||||
s.updatedAt = new Date().toISOString();
|
||||
if (entry) {
|
||||
event(entry, { kind: "state", text: "" });
|
||||
await save(entry);
|
||||
} else await store.save(s);
|
||||
}
|
||||
async function dispatch(entry: Live, prompt: QueuedPrompt) {
|
||||
const s = entry.session;
|
||||
if (s.status !== "ready" || entry.stopping || !entry.channel)
|
||||
throw Error("Agent is not ready");
|
||||
s.status = "running";
|
||||
s.queue = s.queue?.filter((p) => p.id !== prompt.id);
|
||||
event(entry, {
|
||||
kind: "user",
|
||||
text:
|
||||
prompt.text +
|
||||
(prompt.attachmentIds.length
|
||||
? "\n" +
|
||||
prompt.attachmentIds
|
||||
.map((id) => s.attachments!.find((a) => a.id === id)!.name)
|
||||
.join("\n")
|
||||
: ""),
|
||||
});
|
||||
event(entry, { kind: "status", text: "running" });
|
||||
event(entry, { kind: "state", text: "" });
|
||||
await save(entry);
|
||||
entry.channel.write(
|
||||
JSON.stringify({
|
||||
type: "prompt",
|
||||
text: prompt.text,
|
||||
attachments: prompt.attachmentIds.map((id) =>
|
||||
s.attachments!.find((a) => a.id === id),
|
||||
),
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
function drain(entry: Live) {
|
||||
if (entry.draining || entry.stopping) return;
|
||||
entry.draining = true;
|
||||
void ctx
|
||||
.asUser(entry.session.userId, async () => {
|
||||
const s = entry.session;
|
||||
if (!(await allowed(s))) return stop(entry);
|
||||
if (
|
||||
s.status === "ready" &&
|
||||
!s.queuePaused &&
|
||||
!s.archived &&
|
||||
s.queue?.length &&
|
||||
!entry.stopping
|
||||
)
|
||||
await dispatch(entry, s.queue[0]);
|
||||
})
|
||||
.catch((error) => {
|
||||
entry.session.queuePaused = true;
|
||||
event(entry, { kind: "error", text: String(error.message || error) });
|
||||
})
|
||||
.finally(() => {
|
||||
entry.draining = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function start(s: AgentSession): Promise<Live> {
|
||||
const existing = live.get(s.id);
|
||||
if (existing?.stopping) await existing.stopping;
|
||||
else if (existing) return existing;
|
||||
if (
|
||||
[...live.values()].filter((e) => e.session.userId === s.userId).length >=
|
||||
4
|
||||
)
|
||||
throw Error(
|
||||
"Stop an agent before opening another (maximum 4 active sessions)",
|
||||
);
|
||||
const provider = await repository.findProviderWithSecret(
|
||||
s.providerId,
|
||||
s.userId,
|
||||
);
|
||||
if (!provider?.enabled || !compatible(s.agent, provider.providerType))
|
||||
throw Error("Provider protocol is not compatible with this agent");
|
||||
const connection = await ctx.ssh.connect<Client>(s.hostId, {
|
||||
purpose: "ai-agent",
|
||||
profile: "session",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const entry: Live = {
|
||||
// History is loaded so a resumed session keeps numbering its events.
|
||||
session: { ...s, status: "starting", events: await store.events(s.id) },
|
||||
dispose: connection.dispose,
|
||||
subscribers: new Set(),
|
||||
approvals: new Set(),
|
||||
pending: [],
|
||||
saved: Promise.resolve(),
|
||||
};
|
||||
live.set(s.id, entry);
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const aborters = new Set<AbortController>();
|
||||
let port = 0;
|
||||
const proxy = createServer((req, res) => {
|
||||
const controller = new AbortController();
|
||||
aborters.add(controller);
|
||||
res.on("close", () => {
|
||||
controller.abort();
|
||||
aborters.delete(controller);
|
||||
});
|
||||
void ctx
|
||||
.asUser(s.userId, async () => {
|
||||
const path = providerPath(req.url ?? "/");
|
||||
const auth =
|
||||
req.headers.authorization === `Bearer ${token}` ||
|
||||
req.headers["x-api-key"] === token;
|
||||
if (!auth || !path || !["GET", "POST"].includes(req.method ?? "")) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
if (!(await allowed(s))) {
|
||||
res.writeHead(403).end();
|
||||
stop(entry);
|
||||
return;
|
||||
}
|
||||
const current = await repository.findProviderWithSecret(
|
||||
s.providerId,
|
||||
s.userId,
|
||||
);
|
||||
if (!current?.enabled) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
if (Buffer.byteLength(body) > MAX_BODY) {
|
||||
res.writeHead(413).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (body) {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.model && parsed.model !== s.model) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const base =
|
||||
current.baseUrl ||
|
||||
(current.providerType === "anthropic"
|
||||
? "https://api.anthropic.com/v1"
|
||||
: "https://api.openai.com/v1");
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (current.providerType === "anthropic") {
|
||||
headers["x-api-key"] = current.apiKey ?? "";
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else if (current.apiKey)
|
||||
headers.authorization = `Bearer ${current.apiKey}`;
|
||||
const response = await createProviderFetch(
|
||||
ctx.fetch,
|
||||
await readPrivateAllowlist(ctx.settings),
|
||||
)(base.replace(/\/$/, "") + path, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: body || undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
res.writeHead(response.status, {
|
||||
"Content-Type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
if (response.body)
|
||||
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
if (!res.write(chunk))
|
||||
await once(res, "drain", { signal: controller.signal });
|
||||
}
|
||||
res.end();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end('{"error":"Termix provider request failed"}');
|
||||
});
|
||||
});
|
||||
proxy.maxHeadersCount = 30;
|
||||
connection.client.on("tcp connection", (info, accept, reject) => {
|
||||
if (info.destPort !== port || info.destIP !== "127.0.0.1") {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
proxy.emit("connection", accept());
|
||||
});
|
||||
const startup = setTimeout(() => {
|
||||
if (entry.session.status === "starting") {
|
||||
event(entry, { kind: "error", text: "Agent startup timed out" });
|
||||
void stop(entry);
|
||||
}
|
||||
}, 60000);
|
||||
startup.unref();
|
||||
const expiry = setTimeout(() => stop(entry), 8 * 60 * 60 * 1000);
|
||||
expiry.unref();
|
||||
const accessTimer = setInterval(() => {
|
||||
void ctx
|
||||
.asUser(s.userId, async () => {
|
||||
if (!(await allowed(s))) stop(entry);
|
||||
})
|
||||
.catch(() => stop(entry));
|
||||
}, 30000);
|
||||
accessTimer.unref();
|
||||
entry.dispose = () => {
|
||||
clearTimeout(startup);
|
||||
clearTimeout(expiry);
|
||||
clearInterval(accessTimer);
|
||||
for (const c of aborters) c.abort();
|
||||
proxy.close();
|
||||
connection.dispose();
|
||||
};
|
||||
try {
|
||||
port = await new Promise<number>((resolve, reject) =>
|
||||
connection.client.forwardIn("127.0.0.1", 0, (err, p) =>
|
||||
err
|
||||
? reject(
|
||||
Error(
|
||||
"SSH remote forwarding was refused. Allow loopback remote forwarding for this SSH account (AllowTcpForwarding remote and PermitListen 127.0.0.1:*). Installing an agent does not change SSH policy.",
|
||||
),
|
||||
)
|
||||
: resolve(p),
|
||||
),
|
||||
);
|
||||
const source = Buffer.from(REMOTE_RUNNER).toString("base64");
|
||||
const command =
|
||||
'export PATH="$HOME/.local/share/termix-agent-runtime/node/bin:$PATH"; command -v node >/dev/null || { echo "Node.js is missing. Use Install runtime first." >&2; exit 1; }; exec node -e ' +
|
||||
shellQuote(`eval(Buffer.from('${source}','base64').toString())`);
|
||||
const channel = await new Promise<ClientChannel>((resolve, reject) =>
|
||||
connection.client.exec("sh -lc " + shellQuote(command), (err, ch) =>
|
||||
err ? reject(err) : resolve(ch),
|
||||
),
|
||||
);
|
||||
entry.channel = channel;
|
||||
let buffer = "";
|
||||
channel.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
if (buffer.length > MAX_BODY) {
|
||||
event(entry, {
|
||||
kind: "error",
|
||||
text: "Agent output exceeded the protocol limit",
|
||||
});
|
||||
stop(entry);
|
||||
return;
|
||||
}
|
||||
let n: number;
|
||||
while ((n = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, n);
|
||||
buffer = buffer.slice(n + 1);
|
||||
try {
|
||||
const m = JSON.parse(line);
|
||||
if (m.kind === "native" && typeof m.nativeId === "string") {
|
||||
entry.session.nativeId = m.nativeId;
|
||||
save(entry).catch(logFailure);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!["text", "tool", "permission", "status", "error"].includes(
|
||||
m.kind,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
m.kind === "status" &&
|
||||
["ready", "running", "stopped"].includes(m.text)
|
||||
) {
|
||||
if (entry.session.status === m.text) continue;
|
||||
entry.session.status = m.text;
|
||||
}
|
||||
if (m.kind === "error") entry.session.queuePaused = true;
|
||||
if (m.kind === "permission" && typeof m.requestId === "string")
|
||||
entry.approvals.add(m.requestId);
|
||||
event(entry, {
|
||||
kind: m.kind,
|
||||
text: String(m.text ?? "").replaceAll(token, "[session token]"),
|
||||
requestId: m.requestId,
|
||||
choices: m.choices,
|
||||
});
|
||||
if (m.kind === "status" && m.text === "ready") drain(entry);
|
||||
} catch {
|
||||
/* Ignore non-protocol startup chatter. */
|
||||
}
|
||||
}
|
||||
});
|
||||
channel.stderr.on("data", (d: Buffer) =>
|
||||
event(entry, {
|
||||
kind: "error",
|
||||
text: d.toString().replaceAll(token, "[session token]"),
|
||||
}),
|
||||
);
|
||||
channel.on("close", () => stop(entry));
|
||||
channel.write(
|
||||
JSON.stringify({
|
||||
type: "start",
|
||||
config: {
|
||||
...s,
|
||||
userId: undefined,
|
||||
events: undefined,
|
||||
proxyUrl: `http://127.0.0.1:${port}`,
|
||||
token,
|
||||
providerType: provider.providerType,
|
||||
},
|
||||
}) + "\n",
|
||||
);
|
||||
await save(entry);
|
||||
await ctx.audit.record({
|
||||
action: "agent_start",
|
||||
resourceId: String(s.hostId),
|
||||
success: true,
|
||||
details: JSON.stringify({
|
||||
agent: s.agent,
|
||||
sessionId: s.id,
|
||||
providerId: s.providerId,
|
||||
}),
|
||||
});
|
||||
return entry;
|
||||
} catch (error) {
|
||||
stop(entry);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const gate = createAiGate(ctx.settings, () => ctx.currentActor());
|
||||
router.use("/agents", ctx.rbac.require("agents") as never, gate as never);
|
||||
const mutations = new Map<string, Promise<unknown>>();
|
||||
const route =
|
||||
(fn: (req: Request, res: Response) => Promise<unknown>) =>
|
||||
(req: Request, res: Response) => {
|
||||
const id = req.method !== "GET" ? String(req.params.id ?? "") : "";
|
||||
const previous = id ? mutations.get(id) : undefined;
|
||||
const operation = (previous ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => fn(req, res));
|
||||
if (id) mutations.set(id, operation);
|
||||
void operation
|
||||
.catch((error) => {
|
||||
if (!res.headersSent)
|
||||
res.status(400).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Agent operation failed",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (id && mutations.get(id) === operation) mutations.delete(id);
|
||||
});
|
||||
};
|
||||
registerInstallRoute(router, ctx, (hostId) =>
|
||||
[...live.values()].some((entry) => entry.session.hostId === hostId),
|
||||
);
|
||||
router.get(
|
||||
"/agents",
|
||||
route(async (_req, res) => {
|
||||
const sessions: AgentSession[] = [];
|
||||
for (const stored of await store.list(actor())) {
|
||||
if (await allowed(stored))
|
||||
sessions.push(
|
||||
live.get(stored.id)?.session ?? { ...stored, status: "stopped" },
|
||||
);
|
||||
}
|
||||
res.json({
|
||||
agents: AGENTS,
|
||||
sessions: sessions
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.map((s) => ({ ...s, events: undefined, userId: undefined })),
|
||||
});
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents",
|
||||
route(async (req, res) => {
|
||||
if (!validStart(req.body ?? {}))
|
||||
throw Error(
|
||||
"Choose an agent, host, provider, model and absolute working directory",
|
||||
);
|
||||
const s: AgentSession = {
|
||||
id: randomUUID(),
|
||||
userId: actor(),
|
||||
hostId: req.body.hostId,
|
||||
providerId: req.body.providerId,
|
||||
agent: req.body.agent,
|
||||
model: req.body.model,
|
||||
cwd: req.body.cwd,
|
||||
executable: req.body.executable || req.body.agent,
|
||||
status: "starting",
|
||||
events: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!(await allowed(s))) throw Error("SSH access is required");
|
||||
await start(s);
|
||||
res.status(201).json({ id: s.id });
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents/:id/workspace",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
const body = req.body ?? {};
|
||||
if (
|
||||
["upload", "reference"].includes(body.operation) &&
|
||||
(s.attachments?.length ?? 0) >= 100
|
||||
)
|
||||
throw Error("Maximum 100 attachments per session");
|
||||
const result = await workspaceOperation(ctx, s, body);
|
||||
if (["upload", "reference"].includes(body.operation)) {
|
||||
s.attachments = [
|
||||
...(s.attachments ?? []),
|
||||
result as unknown as AgentAttachment,
|
||||
];
|
||||
await persist(s);
|
||||
}
|
||||
if (["upload", "worktree"].includes(body.operation))
|
||||
await ctx.audit.record({
|
||||
action: "agent_workspace_" + body.operation,
|
||||
resourceId: String(s.hostId),
|
||||
success: true,
|
||||
details: JSON.stringify({ sessionId: s.id }),
|
||||
});
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
router.patch(
|
||||
"/agents/:id",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
updateSession(s, req.body ?? {});
|
||||
if (s.archived) {
|
||||
s.queuePaused = true;
|
||||
const entry = live.get(s.id);
|
||||
if (entry) await stop(entry);
|
||||
}
|
||||
await persist(s);
|
||||
res.json(s);
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents/:id/queue",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
updateQueue(s, req.body ?? {});
|
||||
await persist(s);
|
||||
const entry = live.get(s.id);
|
||||
if (entry) drain(entry);
|
||||
res.json({ queue: s.queue ?? [], queuePaused: !!s.queuePaused });
|
||||
}),
|
||||
);
|
||||
router.get(
|
||||
"/agents/:id",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
res.json(live.has(s.id) ? s : { ...s, events: await store.events(s.id) });
|
||||
}),
|
||||
);
|
||||
router.get(
|
||||
"/agents/:id/events",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
res.set({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders();
|
||||
const after = Number(
|
||||
req.headers["last-event-id"] || req.query.after || 0,
|
||||
);
|
||||
const entry = live.get(s.id);
|
||||
const history = entry
|
||||
? s.events.filter((e) => e.seq > after)
|
||||
: await store.events(s.id, after);
|
||||
for (const e of history)
|
||||
res.write(`id: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n`);
|
||||
if (!entry) {
|
||||
res.write(
|
||||
`event: snapshot\ndata: ${JSON.stringify({ status: "stopped" })}\n\n`,
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
entry.subscribers.add(res);
|
||||
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15000);
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
entry.subscribers.delete(res);
|
||||
});
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents/:id/resume",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
if (s.archived) throw Error("Restore the archived session first");
|
||||
await start(s);
|
||||
res.json({ id: s.id });
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents/:id/input",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
const entry = live.get(s.id);
|
||||
if (!entry?.channel) throw Error("Resume the agent first");
|
||||
const { type, text, requestId, allow, value } = req.body ?? {};
|
||||
if (type === "prompt") {
|
||||
if (s.archived) throw Error("Restore the archived session first");
|
||||
await dispatch(entry, promptInput(s, req.body));
|
||||
res.json({ ok: true });
|
||||
return;
|
||||
} else if (type === "answer") {
|
||||
if (
|
||||
!entry.approvals.has(requestId) ||
|
||||
typeof allow !== "boolean" ||
|
||||
(value !== undefined &&
|
||||
(typeof value !== "string" || value.length > 64000))
|
||||
)
|
||||
throw Error("Invalid approval response");
|
||||
entry.approvals.delete(requestId);
|
||||
await ctx.audit.record({
|
||||
action: "agent_approval",
|
||||
resourceId: String(s.hostId),
|
||||
success: true,
|
||||
details: JSON.stringify({ sessionId: s.id, requestId, allow }),
|
||||
});
|
||||
} else if (type === "cancel") {
|
||||
s.queuePaused = true;
|
||||
await persist(s);
|
||||
} else throw Error("Unknown input type");
|
||||
entry.channel.write(
|
||||
JSON.stringify({ type, text, requestId, allow, value }) + "\n",
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
"/agents/:id/stop",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
const entry = live.get(s.id);
|
||||
if (entry) await stop(entry);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
router.delete(
|
||||
"/agents/:id",
|
||||
route(async (req, res) => {
|
||||
const s = await find(String(req.params.id));
|
||||
const entry = live.get(s.id);
|
||||
if (entry) await stop(entry);
|
||||
await entry?.saved.catch(() => undefined);
|
||||
await store.delete(s.id);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fitsText, type AgentSession, type QueuedPrompt } from "./types.js";
|
||||
|
||||
export function promptInput(
|
||||
s: AgentSession,
|
||||
body: Record<string, unknown>,
|
||||
): QueuedPrompt {
|
||||
const text = body.text;
|
||||
const attachmentIds = body.attachmentIds ?? [];
|
||||
if (
|
||||
typeof text !== "string" ||
|
||||
!fitsText(text) ||
|
||||
!Array.isArray(attachmentIds) ||
|
||||
attachmentIds.length > 4 ||
|
||||
attachmentIds.some(
|
||||
(id) =>
|
||||
typeof id !== "string" || !s.attachments?.some((a) => a.id === id),
|
||||
) ||
|
||||
(!text.trim() && !attachmentIds.length)
|
||||
)
|
||||
throw Error("Provide a message and up to four session attachments");
|
||||
return { id: randomUUID(), text, attachmentIds: [...new Set(attachmentIds)] };
|
||||
}
|
||||
export function updateSession(
|
||||
s: AgentSession,
|
||||
body: Record<string, unknown>,
|
||||
): void {
|
||||
if (body.title !== undefined) {
|
||||
if (typeof body.title !== "string" || body.title.length > 120)
|
||||
throw Error("Session title must be at most 120 characters");
|
||||
}
|
||||
if (body.archived !== undefined && typeof body.archived !== "boolean")
|
||||
throw Error("Invalid archive state");
|
||||
if (
|
||||
body.draft !== undefined &&
|
||||
(typeof body.draft !== "string" || !fitsText(body.draft))
|
||||
)
|
||||
throw Error("Draft is too long");
|
||||
if (body.title !== undefined) s.title = (body.title as string).trim();
|
||||
if (body.archived !== undefined) s.archived = body.archived as boolean;
|
||||
if (body.draft !== undefined) s.draft = body.draft as string;
|
||||
}
|
||||
export function updateQueue(
|
||||
s: AgentSession,
|
||||
body: Record<string, unknown>,
|
||||
): void {
|
||||
const queue = s.queue ?? [];
|
||||
if (body.operation === "add") {
|
||||
if (s.archived || queue.length >= 20)
|
||||
throw Error(
|
||||
"Restore the session or remove a queued message (maximum 20)",
|
||||
);
|
||||
s.queue = [...queue, promptInput(s, body)];
|
||||
} else if (body.operation === "update") {
|
||||
const i = queue.findIndex((p) => p.id === body.id);
|
||||
if (i < 0) throw Error("Queued message has already been sent or removed");
|
||||
const updated = promptInput(s, body);
|
||||
s.queue = queue.map((p, n) => (n === i ? { ...updated, id: p.id } : p));
|
||||
} else if (body.operation === "remove") {
|
||||
s.queue = queue.filter((p) => p.id !== body.id);
|
||||
} else if (body.operation === "continue") s.queuePaused = false;
|
||||
else if (body.operation === "pause") s.queuePaused = true;
|
||||
else throw Error("Unknown queue operation");
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { and, asc, desc, eq, gt, lte } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { agentEvents, agentQueue, agentSessions } from "../tables.js";
|
||||
import type { AgentEvent, AgentSession } from "./types.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Tables come from ctx.db.define, which the SDK hands back untyped.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/** Events kept per session; older ones are pruned as new ones land. */
|
||||
export const EVENT_HISTORY = 2000;
|
||||
|
||||
type SessionRow = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
providerId: number;
|
||||
agent: AgentSession["agent"];
|
||||
model: string;
|
||||
cwd: string;
|
||||
executable: string;
|
||||
nativeId: string | null;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
queuePaused: boolean;
|
||||
draft: string | null;
|
||||
attachments: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function toRow(s: AgentSession): SessionRow {
|
||||
return {
|
||||
sessionId: s.id,
|
||||
userId: s.userId,
|
||||
hostId: s.hostId,
|
||||
providerId: s.providerId,
|
||||
agent: s.agent,
|
||||
model: s.model,
|
||||
cwd: s.cwd,
|
||||
executable: s.executable,
|
||||
nativeId: s.nativeId ?? null,
|
||||
title: s.title ?? null,
|
||||
archived: !!s.archived,
|
||||
queuePaused: !!s.queuePaused,
|
||||
draft: s.draft ?? null,
|
||||
attachments: JSON.stringify(s.attachments ?? []),
|
||||
updatedAt: s.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRow(row: SessionRow): AgentSession {
|
||||
return {
|
||||
id: row.sessionId,
|
||||
userId: row.userId,
|
||||
hostId: Number(row.hostId),
|
||||
providerId: Number(row.providerId),
|
||||
agent: row.agent,
|
||||
model: row.model,
|
||||
cwd: row.cwd,
|
||||
executable: row.executable,
|
||||
nativeId: row.nativeId ?? undefined,
|
||||
title: row.title ?? undefined,
|
||||
archived: !!row.archived,
|
||||
queuePaused: !!row.queuePaused,
|
||||
draft: row.draft ?? undefined,
|
||||
attachments: JSON.parse(row.attachments),
|
||||
status: "stopped",
|
||||
events: [],
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent sessions in the plugin's own tables. A session row holds what the
|
||||
* user configured; events and queued prompts are separate rows, so a long run
|
||||
* appends a few rows a second instead of rewriting everything it said.
|
||||
*/
|
||||
export async function createAgentStore(ctx: Pick<PluginContext, "db">) {
|
||||
const { db } = ctx;
|
||||
const sessions: Table = await db.define(agentSessions);
|
||||
const events: Table = await db.define(agentEvents);
|
||||
const queue: Table = await db.define(agentQueue);
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function writeSession(d: Drizzle, s: AgentSession) {
|
||||
const row = toRow(s);
|
||||
const where = eq(sessions.sessionId, s.id);
|
||||
const existing = await d.select().from(sessions).where(where).limit(1);
|
||||
if (existing.length) await d.update(sessions).set(row).where(where);
|
||||
else await d.insert(sessions).values(row);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(userId: string): Promise<AgentSession[]> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
return rows.map(fromRow);
|
||||
},
|
||||
|
||||
/** The session without its events, which only the event stream needs. */
|
||||
async get(userId: string, id: string): Promise<AgentSession | null> {
|
||||
const d = await client();
|
||||
const [row] = await d
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.sessionId, id), eq(sessions.userId, userId)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const s = fromRow(row);
|
||||
const prompts = await d
|
||||
.select()
|
||||
.from(queue)
|
||||
.where(eq(queue.sessionId, id))
|
||||
.orderBy(asc(queue.position));
|
||||
if (prompts.length)
|
||||
s.queue = prompts.map(
|
||||
(p: { promptId: string; text: string; attachmentIds: string }) => ({
|
||||
id: p.promptId,
|
||||
text: p.text,
|
||||
attachmentIds: JSON.parse(p.attachmentIds),
|
||||
}),
|
||||
);
|
||||
return s;
|
||||
},
|
||||
|
||||
async events(id: string, after = 0): Promise<AgentEvent[]> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(eq(events.sessionId, id), gt(events.seq, after)))
|
||||
.orderBy(desc(events.seq))
|
||||
.limit(EVENT_HISTORY);
|
||||
return rows
|
||||
.reverse()
|
||||
.map(
|
||||
(e: {
|
||||
seq: number;
|
||||
kind: AgentEvent["kind"];
|
||||
text: string;
|
||||
requestId: string | null;
|
||||
choices: string | null;
|
||||
}) => ({
|
||||
seq: Number(e.seq),
|
||||
kind: e.kind,
|
||||
text: e.text,
|
||||
...(e.requestId ? { requestId: e.requestId } : {}),
|
||||
...(e.choices ? { choices: JSON.parse(e.choices) } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/** Session fields and queue. Durable before it resolves. */
|
||||
async save(s: AgentSession): Promise<void> {
|
||||
const d = await client();
|
||||
await writeSession(d, s);
|
||||
await d.delete(queue).where(eq(queue.sessionId, s.id));
|
||||
const prompts = s.queue ?? [];
|
||||
if (prompts.length)
|
||||
await d.insert(queue).values(
|
||||
prompts.map((p, position) => ({
|
||||
sessionId: s.id,
|
||||
userId: s.userId,
|
||||
promptId: p.id,
|
||||
position,
|
||||
text: p.text,
|
||||
attachmentIds: JSON.stringify(p.attachmentIds),
|
||||
})),
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
/**
|
||||
* Session fields plus new events. Informational, so it joins the next
|
||||
* periodic save rather than forcing one per batch.
|
||||
*/
|
||||
async append(s: AgentSession, batch: AgentEvent[]): Promise<void> {
|
||||
if (!batch.length) return;
|
||||
const d = await client();
|
||||
const lastSeq = batch[batch.length - 1].seq;
|
||||
await d.insert(events).values(
|
||||
batch.map((e) => ({
|
||||
sessionId: s.id,
|
||||
userId: s.userId,
|
||||
seq: e.seq,
|
||||
kind: e.kind,
|
||||
text: e.text,
|
||||
requestId: e.requestId ?? null,
|
||||
choices: e.choices ? JSON.stringify(e.choices) : null,
|
||||
})),
|
||||
);
|
||||
await d
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.sessionId, s.id),
|
||||
lte(events.seq, lastSeq - EVENT_HISTORY),
|
||||
),
|
||||
);
|
||||
await writeSession(d, s);
|
||||
await db.persist({ lazy: true });
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const d = await client();
|
||||
await d.delete(events).where(eq(events.sessionId, id));
|
||||
await d.delete(queue).where(eq(queue.sessionId, id));
|
||||
await d.delete(sessions).where(eq(sessions.sessionId, id));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentStore = Awaited<ReturnType<typeof createAgentStore>>;
|
||||
@@ -0,0 +1,84 @@
|
||||
export const AGENTS = ["pi", "opencode", "claude", "codex"] as const;
|
||||
export type AgentKind = (typeof AGENTS)[number];
|
||||
export interface AgentEvent {
|
||||
seq: number;
|
||||
kind: "text" | "tool" | "permission" | "status" | "error" | "user" | "state";
|
||||
text: string;
|
||||
requestId?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
export interface AgentAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
}
|
||||
export interface QueuedPrompt {
|
||||
id: string;
|
||||
text: string;
|
||||
attachmentIds: string[];
|
||||
}
|
||||
export interface AgentSession {
|
||||
title?: string;
|
||||
archived?: boolean;
|
||||
draft?: string;
|
||||
queue?: QueuedPrompt[];
|
||||
queuePaused?: boolean;
|
||||
attachments?: AgentAttachment[];
|
||||
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
providerId: number;
|
||||
agent: AgentKind;
|
||||
model: string;
|
||||
cwd: string;
|
||||
executable: string;
|
||||
nativeId?: string;
|
||||
status: "starting" | "ready" | "running" | "stopped" | "error";
|
||||
events: AgentEvent[];
|
||||
updatedAt: string;
|
||||
}
|
||||
export function compatible(agent: AgentKind, providerType: string): boolean {
|
||||
if (agent === "claude") return providerType === "anthropic";
|
||||
if (agent === "codex")
|
||||
return ["openai", "openai_compatible"].includes(providerType);
|
||||
return ["openai", "openai_compatible", "anthropic", "ollama"].includes(
|
||||
providerType,
|
||||
);
|
||||
}
|
||||
/** MySQL stores plugin text as TEXT, which holds 65535 bytes. */
|
||||
export const MAX_TEXT_BYTES = 65535;
|
||||
export function fitsText(value: string): boolean {
|
||||
return Buffer.byteLength(value, "utf8") <= MAX_TEXT_BYTES;
|
||||
}
|
||||
/** Cut to fit; the slack covers a split character decoding to U+FFFD. */
|
||||
export function clipText(value: string): string {
|
||||
if (fitsText(value)) return value;
|
||||
return Buffer.from(value, "utf8")
|
||||
.subarray(0, MAX_TEXT_BYTES - 3)
|
||||
.toString("utf8");
|
||||
}
|
||||
export function shellQuote(value: string): string {
|
||||
return "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
}
|
||||
export function validStart(value: Record<string, unknown>): boolean {
|
||||
return (
|
||||
AGENTS.includes(value.agent as AgentKind) &&
|
||||
Number.isSafeInteger(value.hostId) &&
|
||||
Number(value.hostId) > 0 &&
|
||||
Number.isSafeInteger(value.providerId) &&
|
||||
Number(value.providerId) > 0 &&
|
||||
typeof value.model === "string" &&
|
||||
value.model.length > 0 &&
|
||||
value.model.length <= 200 &&
|
||||
typeof value.cwd === "string" &&
|
||||
value.cwd.startsWith("/") &&
|
||||
value.cwd.length <= 4096 &&
|
||||
!value.cwd.includes("\0") &&
|
||||
(value.executable === undefined ||
|
||||
(typeof value.executable === "string" &&
|
||||
/^[\w./-]{1,1024}$/.test(value.executable)))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { shellQuote, type AgentSession } from "./types.js";
|
||||
|
||||
/** Fixed operations; all paths and content arrive as JSON on stdin, never shell source. */
|
||||
export const REMOTE_WORKSPACE = String.raw`
|
||||
const fs=require('node:fs'),path=require('node:path'),os=require('node:os');
|
||||
const {execFileSync}=require('node:child_process');
|
||||
const MAX=1024*1024;
|
||||
function imageMime(data) {
|
||||
if(data.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return 'image/png';
|
||||
if(data[0]===255 && data[1]===216 && data[2]===255) return 'image/jpeg';
|
||||
if(['GIF87a','GIF89a'].includes(data.subarray(0,6).toString())) return 'image/gif';
|
||||
if(data.subarray(0,4).toString()==='RIFF' && data.subarray(8,12).toString()==='WEBP') return 'image/webp';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
function main(m) {
|
||||
const cwd=fs.realpathSync(m.cwd), body=m.body;
|
||||
const inside=(value,missing=false)=>{
|
||||
if(typeof value!=='string' || value.includes('\0')) throw Error('Invalid path');
|
||||
const requested=path.resolve(cwd,value);
|
||||
let ancestor=requested;
|
||||
if(missing) while(!fs.existsSync(ancestor) && path.dirname(ancestor)!==ancestor) ancestor=path.dirname(ancestor);
|
||||
const p=path.resolve(fs.realpathSync(ancestor),path.relative(ancestor,requested));
|
||||
const relative=path.relative(cwd,p);
|
||||
if(relative==='..' || relative.startsWith('../') || path.isAbsolute(relative)) throw Error('Choose a file inside the working directory');
|
||||
return p;
|
||||
};
|
||||
function file(p) {
|
||||
const fd=fs.openSync(p,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat=fs.fstatSync(fd);
|
||||
if(!stat.isFile() || stat.size>MAX) throw Error('Choose a regular file no larger than 1 MiB');
|
||||
const data=Buffer.alloc(stat.size), size=fs.readSync(fd,data,0,data.length,0);
|
||||
return data.subarray(0,size);
|
||||
} finally {fs.closeSync(fd);}
|
||||
}
|
||||
function git(args) {
|
||||
return execFileSync('git',['--no-pager','-c','core.hooksPath=/dev/null','-c','core.fsmonitor=false',...args],{cwd,encoding:'utf8',maxBuffer:2*MAX,timeout:20000,env:{...process.env,GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0'}});
|
||||
}
|
||||
if(body.operation==='upload' || body.operation==='reference') {
|
||||
let p,data;
|
||||
if(body.operation==='reference') {p=inside(body.path);data=file(p);}
|
||||
else {
|
||||
if(typeof body.name!=='string' || !body.name || body.name.length>255 || body.name.includes('\0') || typeof body.data!=='string' || body.data.length>Math.ceil(MAX/3)*4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(body.data)) throw Error('Invalid upload (maximum 1 MiB)');
|
||||
data=Buffer.from(body.data,'base64');
|
||||
if(data.length>MAX) throw Error('Upload exceeds 1 MiB');
|
||||
const dir=path.join(os.homedir(),'.local','state','termix-agents',m.id,'attachments');
|
||||
fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
p=path.join(dir,require('node:crypto').randomUUID()+'-'+path.basename(body.name).replace(/[^\p{L}\p{N}._-]/gu,'_'));
|
||||
fs.writeFileSync(p,data,{mode:0o600,flag:'wx'});
|
||||
}
|
||||
return {id:require('node:crypto').randomUUID(),name:body.operation==='upload'?path.basename(body.name):path.basename(p),path:p,mime:imageMime(data),size:data.length};
|
||||
}
|
||||
if(body.operation==='status') {
|
||||
const root=git(['rev-parse','--show-toplevel']).trim();
|
||||
const records=git(['status','--porcelain=v1','-z','--untracked-files=all']).split('\0'),files=[];
|
||||
for(let i=0;i<records.length;i++){const row=records[i];if(!row) continue;const rel=path.relative(cwd,path.join(root,row.slice(3)));if(rel!=='..' && !rel.startsWith('../')) files.push({status:row.slice(0,2),path:rel});if(/[RC]/.test(row.slice(0,2))) i++;}
|
||||
return {files,branch:git(['branch','--show-current']),diff:git(['diff','--no-ext-diff','--no-textconv','--stat'])+git(['diff','--cached','--no-ext-diff','--no-textconv','--stat'])};
|
||||
}
|
||||
if(body.operation==='diff') {
|
||||
const p=inside(body.path,true),rel=path.relative(cwd,p);if(fs.existsSync(p)) file(p);
|
||||
let tracked=true;try{git(['ls-files','--error-unmatch','--',rel]);}catch{tracked=false;}
|
||||
if(!tracked) {
|
||||
const data=file(p);
|
||||
return {diff:data.includes(0)?'Binary untracked file':data.toString('utf8').split('\n').map(l=>'+'+l).join('\n')};
|
||||
}
|
||||
return {diff:'--- Unstaged ---\n'+git(['diff','--no-ext-diff','--no-textconv','--',rel])+'\n--- Staged ---\n'+git(['diff','--cached','--no-ext-diff','--no-textconv','--',rel])};
|
||||
}
|
||||
if(body.operation==='worktree') {
|
||||
if(typeof body.branch!=='string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/.test(body.branch)) throw Error('Invalid branch name');
|
||||
git(['check-ref-format','--branch',body.branch]);
|
||||
const dir=path.join(os.homedir(),'.local','state','termix-agents','worktrees');fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const target=path.join(dir,require('node:crypto').randomUUID());
|
||||
git(['worktree','add','-b',body.branch,'--',target,'HEAD']);
|
||||
return {path:target,branch:body.branch};
|
||||
}
|
||||
throw Error('Unknown workspace operation');
|
||||
}
|
||||
let input='';process.stdin.on('data',d=>{input+=d;if(input.length>2*MAX) {process.stderr.write('Request too large');process.exit(1);}});
|
||||
process.stdin.on('end',()=>{try{process.stdout.write(JSON.stringify(main(JSON.parse(input))));}catch(e){process.stderr.write(e.message);process.exitCode=1;}});
|
||||
`;
|
||||
|
||||
export async function workspaceOperation(
|
||||
ctx: PluginContext,
|
||||
s: AgentSession,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (
|
||||
!["upload", "reference", "status", "diff", "worktree"].includes(
|
||||
String(body.operation),
|
||||
)
|
||||
)
|
||||
throw Error("Unknown workspace operation");
|
||||
if (JSON.stringify(body).length > 1500000)
|
||||
throw Error("Upload exceeds 1 MiB");
|
||||
const connection = await ctx.ssh.connect<Client>(s.hostId, {
|
||||
purpose: "ai-agent-workspace",
|
||||
profile: "session",
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
let channel: ClientChannel | undefined;
|
||||
try {
|
||||
const command =
|
||||
'export PATH="$HOME/.local/share/termix-agent-runtime/node/bin:$PATH"; exec node -e ' +
|
||||
shellQuote(REMOTE_WORKSPACE);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
channel?.close();
|
||||
reject(Error("Workspace operation timed out"));
|
||||
}, 30000);
|
||||
connection.client.exec("sh -lc " + shellQuote(command), (error, ch) => {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
channel = ch;
|
||||
let output = "",
|
||||
stderr = "";
|
||||
ch.on("data", (d: Buffer) => {
|
||||
output += d.toString();
|
||||
if (output.length > 3 * 1024 * 1024) {
|
||||
ch.close();
|
||||
reject(Error("Result exceeds 3 MiB; choose a smaller file"));
|
||||
}
|
||||
});
|
||||
ch.stderr.on("data", (d: Buffer) => {
|
||||
stderr = (stderr + d.toString()).slice(-8000);
|
||||
});
|
||||
ch.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
ch.on("close", (code: number) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(Error(stderr || "Workspace operation failed"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(output));
|
||||
} catch {
|
||||
reject(Error("Invalid workspace response"));
|
||||
}
|
||||
});
|
||||
ch.end(JSON.stringify({ id: s.id, cwd: s.cwd, body }));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
connection.dispose();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { createAiRepository } from "./repository.js";
|
||||
import { registerProviderSync } from "./sync.js";
|
||||
import { registerAgentRoutes } from "./agents/routes.js";
|
||||
import { createAgentStore } from "./agents/store.js";
|
||||
import { registerAiRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const repository = await createAiRepository(ctx);
|
||||
await registerProviderSync(ctx, repository);
|
||||
|
||||
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
|
||||
const router = ctx.http.router<Router>();
|
||||
registerAiRoutes(router, repository, ctx);
|
||||
registerAgentRoutes(router, repository, ctx, await createAgentStore(ctx));
|
||||
|
||||
ctx.registry.provide("ai.hostImportNormalizer", hostImportNormalizer);
|
||||
ctx.disposables.add(
|
||||
|
||||
@@ -135,4 +135,88 @@ export const proposals = adoptLegacyTable(
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [providers, conversations, messages, proposals];
|
||||
/*
|
||||
* Coding agent sessions. Events and queued prompts are rows of their own, so a
|
||||
* running agent appends instead of rewriting its session and no single value
|
||||
* grows with the conversation. Text is TEXT on MySQL (64 KB), which is why
|
||||
* prompts, drafts and event text are capped in UTF-8 bytes.
|
||||
*/
|
||||
export const agentSessions = defineTable(
|
||||
"agent_sessions",
|
||||
{
|
||||
id: id(),
|
||||
sessionId: varchar(36).notNull().unique(),
|
||||
userId: refUser(),
|
||||
hostId: integer().notNull(),
|
||||
providerId: integer().notNull(),
|
||||
agent: varchar(32).notNull(),
|
||||
model: text().notNull(),
|
||||
cwd: text().notNull(),
|
||||
executable: text().notNull(),
|
||||
nativeId: text(),
|
||||
title: text(),
|
||||
archived: boolean().notNull().default(false),
|
||||
queuePaused: boolean().notNull().default(false),
|
||||
draft: text(),
|
||||
// At most 100 small records.
|
||||
attachments: text().notNull().default("[]"),
|
||||
updatedAt: text().notNull(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_ai_agent_sessions_user", columns: ["userId"] }],
|
||||
},
|
||||
);
|
||||
|
||||
export const agentEvents = defineTable(
|
||||
"agent_events",
|
||||
{
|
||||
id: id(),
|
||||
sessionId: varchar(36).notNull(),
|
||||
userId: refUser(),
|
||||
seq: integer().notNull(),
|
||||
// text | tool | permission | status | error | user | state
|
||||
kind: varchar(16).notNull(),
|
||||
text: text().notNull(),
|
||||
requestId: text(),
|
||||
choices: text(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_ai_agent_events_session_seq",
|
||||
columns: ["sessionId", "seq"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export const agentQueue = defineTable(
|
||||
"agent_queue",
|
||||
{
|
||||
id: id(),
|
||||
sessionId: varchar(36).notNull(),
|
||||
userId: refUser(),
|
||||
promptId: varchar(36).notNull(),
|
||||
position: integer().notNull(),
|
||||
text: text().notNull(),
|
||||
attachmentIds: text().notNull().default("[]"),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_ai_agent_queue_session",
|
||||
columns: ["sessionId", "position"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export const tables = [
|
||||
providers,
|
||||
conversations,
|
||||
messages,
|
||||
proposals,
|
||||
agentSessions,
|
||||
agentEvents,
|
||||
agentQueue,
|
||||
];
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Textarea } from "@termix/plugin-sdk/ui";
|
||||
import type { AgentSession } from "../../backend/agents/types";
|
||||
import { aiApp } from "../app-ref";
|
||||
import { Attachments } from "./Attachments";
|
||||
|
||||
export interface SessionActions {
|
||||
session: AgentSession;
|
||||
busy: boolean;
|
||||
action: (fn: () => Promise<void>) => Promise<void>;
|
||||
onUpdated: (s: AgentSession) => void;
|
||||
}
|
||||
export function AgentComposer({
|
||||
session: s,
|
||||
busy,
|
||||
action,
|
||||
onUpdated,
|
||||
onComposition,
|
||||
}: SessionActions & { onComposition: (value: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState(s.draft ?? "");
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState("");
|
||||
const composing = useRef(false);
|
||||
const draft = useRef({ text, changed: false });
|
||||
const draftWrites = useRef(Promise.resolve());
|
||||
const persistDraft = useCallback(() => {
|
||||
if (!draft.current.changed) return;
|
||||
const value = draft.current.text;
|
||||
draft.current.changed = false;
|
||||
draftWrites.current = draftWrites.current
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await aiApp().api.patch(`/agents/${s.id}`, { draft: value });
|
||||
});
|
||||
return draftWrites.current;
|
||||
}, [s.id]);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
void persistDraft()?.catch(() => {
|
||||
draft.current.changed = true;
|
||||
});
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, persistDraft]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void persistDraft()?.catch(() => undefined);
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
function change(value: string) {
|
||||
draft.current = { text: value, changed: true };
|
||||
setText(value);
|
||||
}
|
||||
async function refresh() {
|
||||
onUpdated((await aiApp().api.get<AgentSession>(`/agents/${s.id}`)).data);
|
||||
}
|
||||
async function queue(body: unknown) {
|
||||
await aiApp().api.post(`/agents/${s.id}/queue`, body);
|
||||
await refresh();
|
||||
}
|
||||
const canSend =
|
||||
!busy &&
|
||||
!s.archived &&
|
||||
["ready", "running"].includes(s.status) &&
|
||||
(!!text.trim() || !!attachmentIds.length);
|
||||
return (
|
||||
<div className="border-t p-3 space-y-2">
|
||||
{!!s.queue?.length && (
|
||||
<section
|
||||
aria-label={t("agents.queue")}
|
||||
className="max-h-48 overflow-auto space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<strong>
|
||||
{t("agents.queue")} ({s.queue.length})
|
||||
</strong>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void action(() =>
|
||||
queue({ operation: s.queuePaused ? "continue" : "pause" }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(s.queuePaused ? "agents.continueQueue" : "agents.pauseQueue")}
|
||||
</Button>
|
||||
</div>
|
||||
{s.queue.map((p) => (
|
||||
<div key={p.id} className="rounded border p-2 text-sm">
|
||||
{editing === p.id ? (
|
||||
<Textarea
|
||||
aria-label={t("agents.editQueued")}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{p.text}</p>
|
||||
)}
|
||||
{!!p.attachmentIds.length && (
|
||||
<p>
|
||||
{p.attachmentIds
|
||||
.map((id) => s.attachments?.find((a) => a.id === id)?.name)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (editing !== p.id) {
|
||||
setEditing(p.id);
|
||||
setEditText(p.text);
|
||||
return;
|
||||
}
|
||||
void action(async () => {
|
||||
await queue({
|
||||
operation: "update",
|
||||
id: p.id,
|
||||
text: editText,
|
||||
attachmentIds: p.attachmentIds,
|
||||
});
|
||||
setEditing(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(editing === p.id ? "agents.save" : "agents.editQueued")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void action(() => queue({ operation: "remove", id: p.id }))
|
||||
}
|
||||
>
|
||||
{t("agents.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<Attachments
|
||||
session={s}
|
||||
busy={busy}
|
||||
action={action}
|
||||
onUpdated={onUpdated}
|
||||
selected={attachmentIds}
|
||||
onSelected={setAttachmentIds}
|
||||
>
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!canSend) return;
|
||||
void action(async () => {
|
||||
if (s.status === "running" || s.queue?.length)
|
||||
await queue({ operation: "add", text, attachmentIds });
|
||||
else {
|
||||
await aiApp().api.post(`/agents/${s.id}/input`, {
|
||||
type: "prompt",
|
||||
text,
|
||||
attachmentIds,
|
||||
});
|
||||
await refresh();
|
||||
}
|
||||
change("");
|
||||
setAttachmentIds([]);
|
||||
await persistDraft();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
aria-label={t("agents.prompt")}
|
||||
placeholder={t("agents.prompt")}
|
||||
value={text}
|
||||
onChange={(e) => change(e.target.value)}
|
||||
rows={3}
|
||||
onCompositionStart={() => {
|
||||
composing.current = true;
|
||||
onComposition(true);
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composing.current = false;
|
||||
onComposition(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key !== "Enter" ||
|
||||
e.shiftKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey ||
|
||||
e.metaKey ||
|
||||
e.nativeEvent.isComposing ||
|
||||
e.nativeEvent.keyCode === 229 ||
|
||||
composing.current
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
if (!e.repeat) e.currentTarget.form?.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" disabled={!canSend}>
|
||||
{t(
|
||||
s.status === "running" || s.queue?.length
|
||||
? "agents.enqueue"
|
||||
: "agents.send",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || s.status !== "running"}
|
||||
onClick={() =>
|
||||
void action(async () => {
|
||||
await aiApp().api.post(`/agents/${s.id}/input`, {
|
||||
type: "cancel",
|
||||
});
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("agents.interrupt")}
|
||||
</Button>
|
||||
</form>
|
||||
</Attachments>
|
||||
<p className="text-xs text-muted-foreground">{t("agents.shortcuts")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation, type TabProps } from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import { Bot, Plus, Square, Play } from "lucide-react";
|
||||
import { useAgentStream } from "./useAgentStream";
|
||||
import { SessionTools } from "./SessionTools";
|
||||
import { AgentComposer } from "./AgentComposer";
|
||||
import { InstallRuntime } from "./InstallRuntime";
|
||||
import { aiApp } from "../app-ref";
|
||||
import { AiMessage } from "../AiMessage";
|
||||
import { getAiProviders, type AiProvider } from "../ai-api";
|
||||
import {
|
||||
AGENTS,
|
||||
compatible,
|
||||
type AgentEvent,
|
||||
type AgentKind,
|
||||
type AgentSession,
|
||||
} from "../../backend/agents/types";
|
||||
|
||||
const names: Record<AgentKind, string> = {
|
||||
pi: "Pi",
|
||||
opencode: "OpenCode",
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
};
|
||||
function message(error: unknown): string {
|
||||
const e = error as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return e.response?.data?.error || e.message || "Agent request failed";
|
||||
}
|
||||
export function AgentPanel({ host, sshHost }: TabProps) {
|
||||
const { t } = useTranslation();
|
||||
const target = host ?? sshHost;
|
||||
const hostId = Number(target?.id);
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]),
|
||||
[sessions, setSessions] = useState<AgentSession[]>([]);
|
||||
const [agent, setAgent] = useState<AgentKind>("pi"),
|
||||
[providerId, setProviderId] = useState(0),
|
||||
[model, setModel] = useState("");
|
||||
const [cwd, setCwd] = useState("/tmp"),
|
||||
[executable, setExecutable] = useState("");
|
||||
const [active, setActive] = useState<AgentSession | null>(null),
|
||||
[events, setEvents] = useState<AgentEvent[]>([]);
|
||||
const [error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({}),
|
||||
[answered, setAnswered] = useState<Set<string>>(new Set());
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const composing = useRef(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const [p, s] = await Promise.all([
|
||||
getAiProviders(),
|
||||
aiApp().api.get<{ sessions: AgentSession[] }>("/agents"),
|
||||
]);
|
||||
setProviders(p.filter((x) => x.enabled));
|
||||
setSessions(s.data.sessions.filter((x) => x.hostId === hostId));
|
||||
}, [hostId]);
|
||||
useEffect(() => {
|
||||
void refresh().catch((e) => setError(message(e)));
|
||||
}, [refresh]);
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ block: "end" });
|
||||
}, [events]);
|
||||
const activeId = active?.id;
|
||||
const connection = useAgentStream(
|
||||
activeId,
|
||||
generation,
|
||||
(s) => {
|
||||
setActive((old) => (old?.id === s.id ? s : old));
|
||||
setEvents((old) =>
|
||||
[
|
||||
...s.events,
|
||||
...old.filter((e) => e.seq > (s.events.at(-1)?.seq ?? 0)),
|
||||
].slice(-2000),
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
setEvents((old) =>
|
||||
old.some((x) => x.seq === e.seq) ? old : [...old, e].slice(-2000),
|
||||
);
|
||||
if (e.kind === "status")
|
||||
setActive((old) =>
|
||||
old ? { ...old, status: e.text as AgentSession["status"] } : old,
|
||||
);
|
||||
if (e.kind === "error") setError(e.text);
|
||||
},
|
||||
);
|
||||
async function action(fn: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(message(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function select(id: string) {
|
||||
await action(async () => {
|
||||
const r = await aiApp().api.get<AgentSession>(`/agents/${id}`);
|
||||
setActive(r.data);
|
||||
setEvents(r.data.events);
|
||||
setAnswered(new Set());
|
||||
});
|
||||
}
|
||||
async function input(body: unknown) {
|
||||
if (active) await aiApp().api.post(`/agents/${active.id}/input`, body);
|
||||
}
|
||||
const transcript: AgentEvent[] = [];
|
||||
for (const e of events) {
|
||||
const last = transcript.at(-1);
|
||||
if (e.kind === "text" && last?.kind === "text") last.text += e.text;
|
||||
else transcript.push({ ...e });
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col bg-background text-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key !== "Escape" ||
|
||||
e.defaultPrevented ||
|
||||
e.repeat ||
|
||||
e.nativeEvent.isComposing ||
|
||||
e.nativeEvent.keyCode === 229 ||
|
||||
composing.current ||
|
||||
busy ||
|
||||
active?.status !== "running"
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void action(() => input({ type: "cancel" }));
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b p-3">
|
||||
<Bot size={18} />
|
||||
<strong>{t("agents.title")}</strong>
|
||||
<span className="text-muted-foreground">
|
||||
{String(target?.name ?? target?.ip ?? "")}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setActive(null);
|
||||
setEvents([]);
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("agents.new")}
|
||||
</Button>
|
||||
</header>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="m-3 whitespace-pre-wrap border border-destructive p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<aside className="max-h-40 overflow-auto border-b p-2 md:max-h-none md:w-56 md:border-r">
|
||||
<Input
|
||||
aria-label={t("agents.search")}
|
||||
placeholder={t("agents.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<label className="my-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => setShowArchived(e.target.checked)}
|
||||
/>
|
||||
{t("agents.showArchived")}
|
||||
</label>
|
||||
{sessions
|
||||
.filter(
|
||||
(s) =>
|
||||
!!s.archived === showArchived &&
|
||||
[s.title, s.model, s.cwd]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
)
|
||||
.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`mb-1 block w-full rounded p-2 text-left text-sm hover:bg-muted ${active?.id === s.id ? "bg-muted" : ""}`}
|
||||
onClick={() => void select(s.id)}
|
||||
>
|
||||
<div>{s.title || `${names[s.agent]} · ${s.model}`}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{s.cwd}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!sessions.length && (
|
||||
<p className="p-2 text-sm text-muted-foreground">
|
||||
{t("agents.noSessions")}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
{!active ? (
|
||||
<form
|
||||
className="mx-auto w-full max-w-xl space-y-4 overflow-auto p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void action(async () => {
|
||||
const r = await aiApp().api.post<{ id: string }>("/agents", {
|
||||
hostId,
|
||||
agent,
|
||||
providerId,
|
||||
model,
|
||||
cwd,
|
||||
executable: executable || undefined,
|
||||
});
|
||||
const s = await aiApp().api.get<AgentSession>(
|
||||
`/agents/${r.data.id}`,
|
||||
);
|
||||
setActive(s.data);
|
||||
setEvents(s.data.events);
|
||||
await refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("agents.description")}
|
||||
</p>
|
||||
<label className="block space-y-1">
|
||||
<span>{t("agents.runtime")}</span>
|
||||
<select
|
||||
className="w-full rounded border bg-background p-2"
|
||||
value={agent}
|
||||
onChange={(e) => {
|
||||
setAgent(e.target.value as AgentKind);
|
||||
setProviderId(0);
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
{AGENTS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{names[a]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span>{t("agents.provider")}</span>
|
||||
<select
|
||||
required
|
||||
className="w-full rounded border bg-background p-2"
|
||||
value={providerId}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value);
|
||||
setProviderId(id);
|
||||
setModel(
|
||||
providers.find((p) => p.id === id)?.defaultModel ?? "",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value={0}>{t("agents.chooseProvider")}</option>
|
||||
{providers
|
||||
.filter((p) => compatible(agent, p.providerType))
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span>{t("agents.model")}</span>
|
||||
<Input
|
||||
required
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span>{t("agents.directory")}</span>
|
||||
<Input
|
||||
required
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span>{t("agents.executable")}</span>
|
||||
<Input
|
||||
placeholder={agent}
|
||||
value={executable}
|
||||
onChange={(e) => setExecutable(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents.requirements")}
|
||||
</p>
|
||||
{agent === "codex" && (
|
||||
<p className="text-sm">{t("agents.responsesRequired")}</p>
|
||||
)}
|
||||
{agent === "pi" && (
|
||||
<p className="text-sm">{t("agents.piPermissions")}</p>
|
||||
)}
|
||||
<InstallRuntime
|
||||
hostId={hostId}
|
||||
agent={agent}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
/>
|
||||
<Button disabled={busy || !providerId || !model} type="submit">
|
||||
<Play size={14} />
|
||||
{t("agents.start")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
{connection !== "connected" && (
|
||||
<p role="status" className="p-2 text-sm">
|
||||
{t(`agents.${connection}`)}
|
||||
</p>
|
||||
)}
|
||||
<SessionTools
|
||||
onWorktree={(path) => {
|
||||
setCwd(path);
|
||||
setAgent(active.agent);
|
||||
setProviderId(active.providerId);
|
||||
setModel(active.model);
|
||||
setExecutable(active.executable);
|
||||
setActive(null);
|
||||
setEvents([]);
|
||||
}}
|
||||
key={`tools-${active.id}`}
|
||||
session={active}
|
||||
busy={busy}
|
||||
action={action}
|
||||
onUpdated={async (s) => {
|
||||
setActive(s);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b p-2 text-sm">
|
||||
<span>
|
||||
{names[active.agent]} · {active.model} · {active.cwd} ·{" "}
|
||||
{t(`agents.status.${active.status}`)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || active.archived}
|
||||
onClick={() =>
|
||||
void action(async () => {
|
||||
await aiApp().api.post(`/agents/${active.id}/resume`);
|
||||
setActive({ ...active, status: "starting" });
|
||||
setGeneration((x) => x + 1);
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("agents.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void action(async () => {
|
||||
await aiApp().api.post(`/agents/${active.id}/stop`);
|
||||
setActive({ ...active, status: "stopped" });
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Square size={12} />
|
||||
{t("agents.stop")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-4">
|
||||
{transcript
|
||||
.filter((e) => e.kind !== "state")
|
||||
.map((e) => (
|
||||
<div key={e.seq}>
|
||||
{e.kind === "text" || e.kind === "user" ? (
|
||||
<AiMessage
|
||||
role={e.kind === "user" ? "user" : "assistant"}
|
||||
content={e.text}
|
||||
/>
|
||||
) : e.kind === "permission" ? (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<strong>{t("agents.approval")}</strong>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs">
|
||||
{e.text}
|
||||
</pre>
|
||||
{e.choices?.length ? (
|
||||
<select
|
||||
className="w-full border bg-background p-2"
|
||||
value={answers[e.requestId!] ?? ""}
|
||||
onChange={(v) =>
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[e.requestId!]: v.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">{t("agents.answer")}</option>
|
||||
{e.choices.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={t("agents.answer")}
|
||||
value={answers[e.requestId!] ?? ""}
|
||||
onChange={(v) =>
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[e.requestId!]: v.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{[true, false].map((allow) => (
|
||||
<Button
|
||||
key={String(allow)}
|
||||
size="sm"
|
||||
variant={allow ? "default" : "outline"}
|
||||
disabled={answered.has(e.requestId!)}
|
||||
onClick={() =>
|
||||
void action(async () => {
|
||||
await input({
|
||||
type: "answer",
|
||||
requestId: e.requestId,
|
||||
allow,
|
||||
value: answers[e.requestId!],
|
||||
});
|
||||
setAnswered(
|
||||
(a) => new Set([...a, e.requestId!]),
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(allow ? "agents.allow" : "agents.deny")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : e.kind === "tool" ? (
|
||||
<details className="rounded border p-2 text-xs">
|
||||
<summary className="cursor-pointer">
|
||||
{t("agents.tool")}
|
||||
</summary>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap">
|
||||
{e.text}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{e.kind === "status"
|
||||
? t(`agents.status.${e.text}`)
|
||||
: e.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
<AgentComposer
|
||||
key={`composer-${active.id}`}
|
||||
session={active}
|
||||
busy={busy}
|
||||
action={action}
|
||||
onComposition={(value) => {
|
||||
composing.current = value;
|
||||
}}
|
||||
onUpdated={setActive}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import { aiApp } from "../app-ref";
|
||||
import type { AgentAttachment, AgentSession } from "../../backend/agents/types";
|
||||
import type { SessionActions } from "./AgentComposer";
|
||||
|
||||
export function Attachments({
|
||||
session: s,
|
||||
busy,
|
||||
action,
|
||||
onUpdated,
|
||||
selected,
|
||||
onSelected,
|
||||
children,
|
||||
}: SessionActions & {
|
||||
children: ReactNode;
|
||||
selected: string[];
|
||||
onSelected: (ids: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [path, setPath] = useState("");
|
||||
async function attach(body: unknown) {
|
||||
const result = await aiApp().api.post<AgentAttachment>(
|
||||
`/agents/${s.id}/workspace`,
|
||||
body,
|
||||
);
|
||||
onSelected([...selected, result.data.id]);
|
||||
onUpdated((await aiApp().api.get<AgentSession>(`/agents/${s.id}`)).data);
|
||||
}
|
||||
async function upload(file: File) {
|
||||
if (selected.length >= 4 || file.size > 1024 * 1024)
|
||||
throw Error(t("agents.attachmentLimit"));
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(",")[1]);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
await attach({ operation: "upload", name: file.name, data });
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="space-y-2"
|
||||
onPaste={(e) => {
|
||||
const image = Array.from(e.clipboardData.items)
|
||||
.find((i) => i.type.startsWith("image/"))
|
||||
?.getAsFile();
|
||||
if (!image || busy) return;
|
||||
e.preventDefault();
|
||||
void action(() => upload(image));
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="hidden"
|
||||
type="file"
|
||||
aria-label={t("agents.upload")}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void action(() => upload(file));
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || selected.length >= 4}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t("agents.upload")}
|
||||
</Button>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
aria-label={t("agents.remoteFile")}
|
||||
placeholder={t("agents.remoteFile")}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !path || selected.length >= 4}
|
||||
onClick={() =>
|
||||
void action(async () => {
|
||||
await attach({ operation: "reference", path });
|
||||
setPath("");
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("agents.attach")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={t("agents.pasteImage")}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("agents.pasteImage")}
|
||||
</div>
|
||||
{!!s.attachments?.length && (
|
||||
<select
|
||||
className="max-w-full rounded border bg-background p-1 text-sm"
|
||||
aria-label={t("agents.attachments")}
|
||||
value=""
|
||||
disabled={busy || selected.length >= 4}
|
||||
onChange={(e) => {
|
||||
if (e.target.value && !selected.includes(e.target.value))
|
||||
onSelected([...selected, e.target.value]);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("agents.attachments")}</option>
|
||||
{s.attachments.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name} ({a.size} B)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSelected(selected.filter((x) => x !== id))}
|
||||
>
|
||||
{s.attachments?.find((a) => a.id === id)?.name} ×
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Download, Network } from "lucide-react";
|
||||
import { aiApp } from "../app-ref";
|
||||
import type { AgentKind } from "../../backend/agents/types";
|
||||
|
||||
export function InstallRuntime({
|
||||
hostId,
|
||||
agent,
|
||||
busy,
|
||||
setBusy,
|
||||
}: {
|
||||
hostId: number;
|
||||
agent: AgentKind;
|
||||
busy: boolean;
|
||||
setBusy: (busy: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [log, setLog] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
const request = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
async function install(operation: "install" | "forwarding") {
|
||||
const failed =
|
||||
operation === "install"
|
||||
? "agents.installFailed"
|
||||
: "agents.forwardingFailed";
|
||||
setBusy(true);
|
||||
setLog("");
|
||||
setResult("");
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
try {
|
||||
const response = await aiApp().fetch(`agents/${operation}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hostId, agent }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw Error((await response.json()).error || t(failed));
|
||||
if (!response.body) throw Error(t(failed));
|
||||
const reader = response.body.getReader(),
|
||||
decoder = new TextDecoder();
|
||||
let buffer = "",
|
||||
completed = false;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
let n: number;
|
||||
while ((n = buffer.indexOf("\n")) >= 0) {
|
||||
const event = JSON.parse(buffer.slice(0, n));
|
||||
buffer = buffer.slice(n + 1);
|
||||
if (event.log) setLog((old) => (old + event.log).slice(-64000));
|
||||
if (event.done) {
|
||||
completed = true;
|
||||
if (!event.success) throw Error(event.error || t(failed));
|
||||
setResult(
|
||||
t(
|
||||
operation === "install"
|
||||
? "agents.installSucceeded"
|
||||
: "agents.forwardingSucceeded",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!completed) throw Error(t(failed));
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setResult(error instanceof Error ? error.message : t(failed));
|
||||
} finally {
|
||||
request.current = null;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents.installSource")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void install("install")}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("agents.installRuntime")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents.forwardingScope")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void install("forwarding")}
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("agents.enableForwarding")}
|
||||
</Button>
|
||||
{result && (
|
||||
<p role="status" className="text-sm">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
{log && (
|
||||
<pre
|
||||
aria-label={t("agents.installLog")}
|
||||
className="max-h-48 overflow-auto whitespace-pre-wrap text-xs"
|
||||
>
|
||||
{log}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import { aiApp } from "../app-ref";
|
||||
import type { AgentSession } from "../../backend/agents/types";
|
||||
import type { SessionActions } from "./AgentComposer";
|
||||
|
||||
export function SessionTools({
|
||||
session: s,
|
||||
busy,
|
||||
action,
|
||||
onUpdated,
|
||||
onWorktree,
|
||||
}: SessionActions & { onWorktree: (path: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState(s.title ?? ""),
|
||||
[file, setFile] = useState(""),
|
||||
[branch, setBranch] = useState("");
|
||||
const [result, setResult] = useState(""),
|
||||
[worktree, setWorktree] = useState("");
|
||||
async function patch(body: unknown) {
|
||||
onUpdated(
|
||||
(await aiApp().api.patch<AgentSession>(`/agents/${s.id}`, body)).data,
|
||||
);
|
||||
}
|
||||
const [files, setFiles] = useState<{ status: string; path: string }[]>([]);
|
||||
async function inspect(operation: string, path = file) {
|
||||
const r = await aiApp().api.post<{
|
||||
status?: string;
|
||||
files?: { status: string; path: string }[];
|
||||
diff?: string;
|
||||
branch?: string;
|
||||
path?: string;
|
||||
}>(`/agents/${s.id}/workspace`, { operation, path, branch });
|
||||
if (r.data.files) setFiles(r.data.files);
|
||||
setResult(
|
||||
[r.data.branch, r.data.status, r.data.diff, r.data.path]
|
||||
.filter(Boolean)
|
||||
.join("\n") || t("agents.clean"),
|
||||
);
|
||||
if (r.data.path) setWorktree(r.data.path);
|
||||
}
|
||||
return (
|
||||
<div className="border-b p-2 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
aria-label={t("agents.sessionName")}
|
||||
placeholder={t("agents.sessionName")}
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void action(() => patch({ title }))}
|
||||
>
|
||||
{t("agents.rename")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void action(() => patch({ archived: !s.archived }))}
|
||||
>
|
||||
{t(s.archived ? "agents.restore" : "agents.archive")}
|
||||
</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("agents.review")}
|
||||
</summary>
|
||||
<div className="my-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void action(() => inspect("status"))}
|
||||
>
|
||||
{t("agents.gitStatus")}
|
||||
</Button>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
aria-label={t("agents.diffFile")}
|
||||
placeholder={t("agents.diffFile")}
|
||||
value={file}
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !file}
|
||||
onClick={() => void action(() => inspect("diff"))}
|
||||
>
|
||||
{t("agents.diff")}
|
||||
</Button>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
aria-label={t("agents.branch")}
|
||||
placeholder={t("agents.branch")}
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !branch}
|
||||
onClick={() => void action(() => inspect("worktree"))}
|
||||
>
|
||||
{t("agents.createWorktree")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents.worktreeHint")}
|
||||
</p>
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-32 overflow-auto my-2">
|
||||
{files.map((f) => (
|
||||
<button
|
||||
key={f.path}
|
||||
className="block text-left text-sm hover:underline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setFile(f.path);
|
||||
void action(() => inspect("diff", f.path));
|
||||
}}
|
||||
>
|
||||
{f.status} {f.path}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{worktree && (
|
||||
<Button size="sm" onClick={() => onWorktree(worktree)}>
|
||||
{t("agents.useWorktree")}
|
||||
</Button>
|
||||
)}
|
||||
{result && (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded border p-2 text-xs">
|
||||
{result}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { aiApp } from "../app-ref";
|
||||
import type { AgentEvent, AgentSession } from "../../backend/agents/types";
|
||||
|
||||
export function useAgentStream(
|
||||
id: string | undefined,
|
||||
generation: number,
|
||||
snapshot: (s: AgentSession) => void,
|
||||
event: (e: AgentEvent) => void,
|
||||
) {
|
||||
const callbacks = useRef({ snapshot, event });
|
||||
callbacks.current = { snapshot, event };
|
||||
const [connection, setConnection] = useState("connected");
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const controller = new AbortController();
|
||||
let cursor = 0,
|
||||
attempt = 0;
|
||||
const refresh = async () => {
|
||||
const r = await aiApp().api.get<AgentSession>(`/agents/${id}`);
|
||||
if (controller.signal.aborted) return;
|
||||
cursor = Math.max(cursor, r.data.events.at(-1)?.seq ?? 0);
|
||||
callbacks.current.snapshot(r.data);
|
||||
return r.data;
|
||||
};
|
||||
void (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
const s = await refresh();
|
||||
if (!s || controller.signal.aborted) return;
|
||||
if (s.status === "stopped") {
|
||||
setConnection("connected");
|
||||
return;
|
||||
}
|
||||
const response = await aiApp().fetch(
|
||||
`agents/${id}/events?after=${cursor}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if ([401, 403, 404].includes(response.status)) {
|
||||
setConnection("unavailable");
|
||||
return;
|
||||
}
|
||||
if (!response.ok || !response.body) throw Error("Stream unavailable");
|
||||
setConnection("connected");
|
||||
const reader = response.body.getReader(),
|
||||
decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (!controller.signal.aborted) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
attempt = 0;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf("\n\n")) >= 0) {
|
||||
const frame = buffer.slice(0, end);
|
||||
buffer = buffer.slice(end + 2);
|
||||
const line = frame
|
||||
.split("\n")
|
||||
.find((l) => l.startsWith("data: "));
|
||||
if (!line) continue;
|
||||
if (frame.includes("event: snapshot")) {
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
const e: AgentEvent = JSON.parse(line.slice(6));
|
||||
if (e.seq <= cursor) continue;
|
||||
cursor = e.seq;
|
||||
callbacks.current.event(e);
|
||||
if (e.kind === "state" || e.kind === "error") await refresh();
|
||||
if (e.kind === "status" && e.text === "stopped") return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } }).response
|
||||
?.status;
|
||||
if ([400, 401, 403, 404].includes(status ?? 0)) {
|
||||
setConnection("unavailable");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
setConnection("reconnecting");
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
controller.signal.removeEventListener("abort", done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(
|
||||
done,
|
||||
Math.min(1000 * 2 ** attempt++, 10000),
|
||||
);
|
||||
controller.signal.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [id, generation]);
|
||||
return connection;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TerminalSidePanelProps,
|
||||
TerminalSlotApi,
|
||||
} from "./terminal/terminal-slot-types";
|
||||
import { AgentPanel } from "./agents/AgentPanel";
|
||||
import { AiPanel } from "./AiPanel";
|
||||
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
||||
import { AiAssistantStep } from "./AiAssistantStep";
|
||||
@@ -63,6 +64,12 @@ export function activate(app: TermixApp): void {
|
||||
|
||||
let globallyEnabled = false;
|
||||
let userEnabled = false;
|
||||
let agentsAllowed = false;
|
||||
void app.hasPermission("agents").then((allowed) => {
|
||||
if (disposed) return;
|
||||
agentsAllowed = allowed;
|
||||
applyStatus();
|
||||
});
|
||||
const surface: (() => void)[] = [];
|
||||
|
||||
const clearSurface = () => {
|
||||
@@ -103,6 +110,26 @@ export function activate(app: TermixApp): void {
|
||||
}),
|
||||
);
|
||||
|
||||
surface.push(
|
||||
app.registerTab("ai-agents", AgentPanel, {
|
||||
icon: Bot,
|
||||
titleKey: "agents.title",
|
||||
requiresHost: true,
|
||||
persistent: true,
|
||||
}),
|
||||
);
|
||||
surface.push(
|
||||
app.registerHostAction({
|
||||
id: "ai-agents",
|
||||
titleKey: "agents.title",
|
||||
icon: Bot,
|
||||
kind: "open",
|
||||
order: 35,
|
||||
tabType: "ai-agents",
|
||||
when: (host) => userEnabled && agentsAllowed && !!host.enableSsh,
|
||||
}),
|
||||
);
|
||||
|
||||
// The per-host switch is this plugin's host setting.
|
||||
const offeredOnHost = (context: Record<string, unknown>) =>
|
||||
userEnabled &&
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createAgentStore } from "../../../src/backend/agents/store.js";
|
||||
import type { AgentEvent } from "../../../src/backend/agents/types.js";
|
||||
import { startServer, type TestServer } from "../helpers.js";
|
||||
let server: TestServer;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
async function seed(id: string, events: Omit<AgentEvent, "seq">[] = []) {
|
||||
const store = await createAgentStore(server.mock.ctx);
|
||||
const session = {
|
||||
id,
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
agent: "pi" as const,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
executable: "pi",
|
||||
status: "stopped" as const,
|
||||
events: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await store.save(session);
|
||||
await store.append(
|
||||
session,
|
||||
events.map((e, i) => ({ ...e, seq: i + 1 })),
|
||||
);
|
||||
}
|
||||
it("requires the separate agent permission even when AI is enabled", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect((await server.request("GET", "/agents")).status).toBe(403);
|
||||
});
|
||||
it("respects the global AI gate for agent routes", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
expect((await server.request("GET", "/agents")).status).toBe(403);
|
||||
await server.enableFor("user-1");
|
||||
expect((await server.request("GET", "/agents")).status).toBe(200);
|
||||
});
|
||||
it("does not expose another user's persisted session or allow a malformed launch", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
await server.enableFor("user-2");
|
||||
const id = "11111111-1111-1111-1111-111111111111";
|
||||
await seed(id, [{ kind: "text", text: "private transcript" }]);
|
||||
const other = await server.request("GET", `/agents/${id}`, {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(other.status).toBe(400);
|
||||
expect(JSON.stringify(other.body)).not.toContain("private transcript");
|
||||
const invalid = await server.request("POST", "/agents", {
|
||||
body: {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
executable: "pi; uname",
|
||||
},
|
||||
});
|
||||
expect(invalid.status).toBe(400);
|
||||
});
|
||||
it("gates installation before SSH and rejects caller-supplied commands", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/install", {
|
||||
body: { hostId: 1, agent: "pi" },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
it("rejects arbitrary installer names", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/install", {
|
||||
body: { hostId: 1, agent: "pi; id" },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
it("requires agent permission for forwarding configuration", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/forwarding", {
|
||||
body: { hostId: 1 },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("persists session metadata and queued drafts without exposing them to another user", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
await server.enableFor("user-2");
|
||||
const id = "22222222-2222-2222-2222-222222222222";
|
||||
await seed(id);
|
||||
expect(
|
||||
(
|
||||
await server.request("PATCH", `/agents/${id}`, {
|
||||
body: { title: "Private", draft: "Keep this draft" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "add", text: "queued" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const s = (await server.request("GET", `/agents/${id}`)).body;
|
||||
expect(s).toMatchObject({
|
||||
title: "Private",
|
||||
draft: "Keep this draft",
|
||||
queue: [{ text: "queued" }],
|
||||
});
|
||||
for (const [method, path, body] of [
|
||||
["PATCH", `/agents/${id}`, { title: "hijack" }],
|
||||
["POST", `/agents/${id}/queue`, { operation: "add", text: "injected" }],
|
||||
["POST", `/agents/${id}/workspace`, { operation: "status" }],
|
||||
] as const)
|
||||
expect(
|
||||
(await server.request(method, path, { user: "user-2", body })).status,
|
||||
).toBe(400);
|
||||
await server.request("PATCH", `/agents/${id}`, { body: { archived: true } });
|
||||
expect((await server.request("POST", `/agents/${id}/resume`)).status).toBe(
|
||||
400,
|
||||
);
|
||||
expect((await server.request("GET", `/agents/${id}`)).body).toMatchObject({
|
||||
archived: true,
|
||||
queuePaused: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
compatible,
|
||||
shellQuote,
|
||||
validStart,
|
||||
} from "../../../src/backend/agents/types.js";
|
||||
import { providerPath } from "../../../src/backend/agents/routes.js";
|
||||
import { REMOTE_RUNNER } from "../../../src/backend/agents/remote-runner.js";
|
||||
|
||||
describe("remote agent boundaries", () => {
|
||||
it("rejects shell syntax and invalid targets", () => {
|
||||
const start = {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
model: "glm",
|
||||
cwd: "/tmp",
|
||||
};
|
||||
expect(validStart(start)).toBe(true);
|
||||
for (const patch of [
|
||||
{ cwd: "relative" },
|
||||
{ hostId: -1 },
|
||||
{ agent: "shell" },
|
||||
{ executable: "pi; id" },
|
||||
{ cwd: "/tmp\0" },
|
||||
])
|
||||
expect(validStart({ ...start, ...patch })).toBe(false);
|
||||
expect(shellQuote("it's $(secret)")).toBe("'it'\\''s $(secret)'");
|
||||
});
|
||||
it("keeps model protocols explicit", () => {
|
||||
expect(compatible("claude", "openai_compatible")).toBe(false);
|
||||
expect(compatible("pi", "openai_compatible")).toBe(true);
|
||||
expect(compatible("codex", "gemini")).toBe(false);
|
||||
});
|
||||
it("limits the reverse proxy to inference endpoints", () => {
|
||||
expect(providerPath("/v1/chat/completions")).toBe("/chat/completions");
|
||||
expect(providerPath("/v1/responses")).toBe("/responses");
|
||||
expect(providerPath("/v1/files")).toBeNull();
|
||||
expect(providerPath("/v1/../admin")).toBeNull();
|
||||
expect(providerPath("//evil.test/secrets")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const fake = String.raw`#!/usr/bin/env node
|
||||
const fs=require('fs'),http=require('http'),rl=require('readline');
|
||||
const emit=m=>console.log(JSON.stringify(m));
|
||||
const args=process.argv.slice(2);
|
||||
if(args.includes('--session') && require('path').dirname(args[args.indexOf('--session')+1])===process.env.PI_CODING_AGENT_DIR) {console.error('Pi migrates session files from its config root');process.exit(1);}
|
||||
if(args[0]==='serve') {
|
||||
let stream;const port=Number(args[args.indexOf('--port')+1]);
|
||||
http.createServer(async(q,r)=>{
|
||||
if(!process.env.OPENCODE_SERVER_PASSWORD || q.headers.authorization!=='Basic '+Buffer.from('opencode:'+process.env.OPENCODE_SERVER_PASSWORD).toString('base64')){r.statusCode=401;r.end();return;}
|
||||
if(q.url==='/event'){r.writeHead(200,{'Content-Type':'text/event-stream'});r.write(': hello\n\n');stream=r;return;}
|
||||
r.setHeader('Content-Type','application/json');
|
||||
if(q.url==='/session')return r.end(JSON.stringify({id:'oc-session'}));
|
||||
if(q.url.includes('prompt_async')){let body='';for await(const d of q)body+=d;const parsed=JSON.parse(body);if(!parsed.parts.some(p=>p.type==='file' && p.mime==='image/png' && p.url==='data:image/png;base64,aW1hZ2U=')){r.statusCode=400;r.end('{}');return;}r.statusCode=204;r.end();setTimeout(()=>{stream.write('data: '+JSON.stringify({type:'message.part.updated',properties:{part:{id:'text-1',type:'text',sessionID:'oc-session'}}})+'\n\n');stream.write('data: '+JSON.stringify({type:'message.part.delta',properties:{sessionID:'oc-session',partID:'text-1',field:'text',delta:'verified'}})+'\n\n');stream.write('data: '+JSON.stringify({type:'session.idle',properties:{sessionID:'oc-session'}})+'\n\n');},30);return;}
|
||||
r.end('{}');
|
||||
}).listen(port,'127.0.0.1');
|
||||
} else rl.createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line);
|
||||
if(args[0]==='app-server') {
|
||||
if(m.method==='initialize')emit({id:m.id,result:{}});
|
||||
if(m.method==='thread/start'||m.method==='thread/resume')emit({id:m.id,result:{thread:{id:'codex-thread'}}});
|
||||
if(m.method==='turn/start'){if(!m.params.input.some(i=>i.type==='image' && i.url==='data:image/png;base64,aW1hZ2U='))throw Error('Missing Codex image');emit({id:m.id,result:{}});emit({method:'item/agentMessage/delta',params:{delta:'verified'}});emit({method:'turn/completed',params:{turn:{status:'completed'}}});}
|
||||
} else if(args.includes('--input-format')) {
|
||||
if(m.type==='user'){if(!m.message.content.some(i=>i.type==='image' && i.source.media_type==='image/png' && i.source.data==='aW1hZ2U='))throw Error('Missing Claude image');}if(m.type==='user')emit({type:'control_request',request_id:'approval',request:{subtype:'can_use_tool',tool_name:'Read',input:{path:'/tmp'}}});
|
||||
if(m.type==='control_response'){emit({type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text:'verified'}}});emit({type:'result',is_error:false});}
|
||||
} else if(m.type==='get_state') {emit({type:'response',id:m.id,success:true,data:{sessionId:'pi-session'}});
|
||||
} else if(m.type==='prompt') {
|
||||
if(m.images?.[0]?.data!=='aW1hZ2U=' || m.images?.[0]?.mimeType!=='image/png')throw Error('Missing Pi image');
|
||||
emit({type:'message_update',assistantMessageEvent:{type:'text_delta',delta:'verified'}});emit({type:'agent_end'});
|
||||
}
|
||||
});
|
||||
`;
|
||||
for (const agent of ["pi", "claude", "codex", "opencode"] as const) {
|
||||
it(`${agent}: native protocol streams a turn and returns to ready`, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-agent-test-"));
|
||||
const executable = join(dir, "fake-agent");
|
||||
const image = join(dir, "image.png");
|
||||
await writeFile(image, "image");
|
||||
await writeFile(executable, fake, { mode: 0o700 });
|
||||
const child = spawn(process.execPath, ["-e", REMOTE_RUNNER], {
|
||||
env: { ...process.env, HOME: dir },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const events: Record<string, unknown>[] = [];
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d));
|
||||
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(Error("timeout " + stderr + JSON.stringify(events))),
|
||||
12000,
|
||||
);
|
||||
let prompted = false;
|
||||
createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const m = JSON.parse(line);
|
||||
events.push(m);
|
||||
if (m.kind === "error") {
|
||||
clearTimeout(timer);
|
||||
reject(Error(m.text));
|
||||
}
|
||||
if (m.kind === "permission")
|
||||
send({ type: "answer", requestId: m.requestId, allow: true });
|
||||
if (m.kind === "status" && m.text === "ready") {
|
||||
if (!prompted) {
|
||||
prompted = true;
|
||||
send({
|
||||
type: "prompt",
|
||||
text: "hello",
|
||||
attachments: [{ path: image, mime: "image/png" }],
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
send({
|
||||
type: "start",
|
||||
config: {
|
||||
agent,
|
||||
id: "test-session",
|
||||
cwd: dir,
|
||||
executable,
|
||||
model: "test-model",
|
||||
proxyUrl: "http://127.0.0.1:1",
|
||||
token: "temporary-token",
|
||||
providerType:
|
||||
agent === "claude" ? "anthropic" : "openai_compatible",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(
|
||||
events.some((e) => e.kind === "text" && e.text === "verified"),
|
||||
).toBe(true);
|
||||
if (agent === "claude")
|
||||
expect(events.some((e) => e.kind === "permission")).toBe(true);
|
||||
} finally {
|
||||
send({ type: "stop" });
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, writeFile, readFile, mkdir, rm } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
FORWARDING_ADMIN_SCRIPT,
|
||||
forwardingScript,
|
||||
} from "../../../src/backend/agents/forwarding.js";
|
||||
|
||||
async function fixture(mode = "no") {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-"));
|
||||
const bin = join(dir, "bin");
|
||||
await mkdir(bin);
|
||||
const original = `Port 2222\nAllowTcpForwarding ${mode}\nMatch Address 10.1.1.3\n DenyUsers root\n`;
|
||||
await writeFile(join(dir, "sshd_config"), original);
|
||||
await writeFile(
|
||||
join(bin, "sshd"),
|
||||
`#!${process.execPath}
|
||||
const fs=require('fs'),a=process.argv.slice(2),dir=${JSON.stringify(dir)};
|
||||
const file=a.includes('-f')?a[a.indexOf('-f')+1]:dir+'/sshd_config';
|
||||
if(a.includes('-t')){if(a.includes('-f')&&fs.existsSync(dir+'/invalid-candidate'))process.exit(1);process.exit(0);}
|
||||
const text=fs.readFileSync(file,'utf8'),rule=text.match(/Match User root Address 10.1.1.35\\n AllowTcpForwarding (\\w+)/);
|
||||
console.log('allowtcpforwarding '+(rule?rule[1]:${JSON.stringify(mode)}));
|
||||
console.log('permitlisten '+(rule?'127.0.0.1:*':'any'));
|
||||
console.log('gatewayports no\\ndisableforwarding no');
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
join(bin, "systemctl"),
|
||||
`#!/bin/sh
|
||||
if [ "$1" = reload ] && [ -f '${dir}/fail-reload' ]; then rm '${dir}/fail-reload'; exit 1; fi
|
||||
exit 0
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
const script = FORWARDING_ADMIN_SCRIPT.replaceAll(
|
||||
"/usr/sbin/sshd",
|
||||
join(bin, "sshd"),
|
||||
).replaceAll("/etc/ssh", dir);
|
||||
const run = () =>
|
||||
execFileSync("sh", ["-c", script, "termix", "root", "10.1.1.35"], {
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
stdio: "pipe",
|
||||
}).toString();
|
||||
return {
|
||||
dir,
|
||||
original,
|
||||
run,
|
||||
config: () => readFile(join(dir, "sshd_config"), "utf8"),
|
||||
close: () => rm(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
it("adds a scoped rule before existing Match blocks and is idempotent", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
expect(f.run()).toContain("Enabled loopback remote forwarding");
|
||||
const config = await f.config();
|
||||
expect(config).toContain(
|
||||
"AllowTcpForwarding no\n# BEGIN Termix Agent root 10.1.1.35\nMatch User root Address 10.1.1.35\n AllowTcpForwarding remote\n PermitListen 127.0.0.1:*\n GatewayPorts no",
|
||||
);
|
||||
expect(config).toContain("Match Address 10.1.1.3\n DenyUsers root");
|
||||
expect(f.run()).toContain("already allowed");
|
||||
expect(await f.config()).toBe(config);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
it("preserves existing local forwarding", async () => {
|
||||
const f = await fixture("local");
|
||||
try {
|
||||
f.run();
|
||||
expect(await f.config()).toContain(" AllowTcpForwarding yes");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
for (const failure of ["invalid-candidate", "fail-reload"]) {
|
||||
it(`retains the original SSH configuration after ${failure}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await writeFile(join(f.dir, failure), "1");
|
||||
expect(f.run).toThrow();
|
||||
expect(await f.config()).toBe(f.original);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
it("rejects a malformed connection source before privilege escalation", () => {
|
||||
expect(() =>
|
||||
execFileSync("sh", ["-c", forwardingScript()], {
|
||||
env: { ...process.env, SSH_CONNECTION: "1.2.3.4;bad 123 1.2.3.5 22" },
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { installScript } from "../../../src/backend/agents/install.js";
|
||||
|
||||
it("refuses corrupted Node.js downloads before extracting or installing", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-install-"));
|
||||
try {
|
||||
const bin = join(dir, "bin");
|
||||
await mkdir(bin);
|
||||
await writeFile(
|
||||
join(bin, "curl"),
|
||||
`#!/bin/sh
|
||||
for arg do previous="$last"; last="$arg"; done
|
||||
case "$last" in
|
||||
*SHASUMS256.txt) printf '%064d node-v24.1.0-linux-x64.tar.gz\\n' 0 > "$last";;
|
||||
*) echo corrupted > "$last";;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
join(bin, "uname"),
|
||||
'#!/bin/sh\nif [ "$1" = -s ]; then echo Linux; else echo x86_64; fi\n',
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
let output = "";
|
||||
try {
|
||||
execFileSync("sh", ["-c", installScript("pi")], {
|
||||
env: { ...process.env, HOME: dir, PATH: `${bin}:${process.env.PATH}` },
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
output = String((error as { stdout: Buffer }).stdout);
|
||||
}
|
||||
expect(output).toContain("Node.js SHA-256 verification failed");
|
||||
await expect(
|
||||
readFile(join(dir, ".local/share/termix-agent-runtime/node/bin/node")),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a mirror dependency before npm ci", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-registry-"));
|
||||
try {
|
||||
const script = installScript("pi")
|
||||
.split("node - <<'VERIFY'\n")[1]
|
||||
.split("\nVERIFY")[0];
|
||||
await writeFile(
|
||||
join(dir, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
"node_modules/injected": {
|
||||
version: "1.0.0",
|
||||
resolved: "https://mirror.invalid/pkg.tgz",
|
||||
integrity: "sha512-fake",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, ["-e", script], {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Duplex, PassThrough } from "node:stream";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { startServer, type TestServer } from "../helpers.js";
|
||||
let server: TestServer;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
it("dispatches FIFO once per completed turn and pauses pending work when cancelled", async () => {
|
||||
server = await startServer({
|
||||
permissions: ["ai.agents", "ai.manage_providers"],
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
const prompts: { text: string }[] = [];
|
||||
class Channel extends Duplex {
|
||||
stderr = new PassThrough();
|
||||
_read() {}
|
||||
_write(chunk: Buffer, _encoding: string, done: () => void) {
|
||||
const m = JSON.parse(chunk.toString());
|
||||
if (m.type === "start") this.complete();
|
||||
if (m.type === "prompt") prompts.push(m);
|
||||
if (m.type === "stop") {
|
||||
this.push(null);
|
||||
queueMicrotask(() => this.emit("close"));
|
||||
}
|
||||
done();
|
||||
}
|
||||
complete() {
|
||||
this.push(JSON.stringify({ kind: "status", text: "ready" }) + "\n");
|
||||
}
|
||||
}
|
||||
const channel = new Channel();
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
forwardIn: (
|
||||
_host: string,
|
||||
_port: number,
|
||||
callback: (error: null, port: number) => void,
|
||||
) => callback(null, 30000),
|
||||
exec: (_cmd: string, callback: (error: null, ch: Channel) => void) =>
|
||||
callback(null, channel),
|
||||
});
|
||||
vi.spyOn(server.mock.ctx.ssh, "connect").mockResolvedValue({
|
||||
client,
|
||||
dispose: () => channel.destroy(),
|
||||
} as never);
|
||||
const p = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Test",
|
||||
apiKey: "unused",
|
||||
defaultModel: "test",
|
||||
},
|
||||
});
|
||||
const started = await server.request("POST", "/agents", {
|
||||
body: {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: p.body.provider.id,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
});
|
||||
expect(started.status).toBe(201);
|
||||
const id = started.body.id;
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/input`, {
|
||||
body: { type: "prompt", text: "first" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
for (const text of ["second", "third"])
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "add", text },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first"]);
|
||||
channel.complete();
|
||||
await vi.waitFor(() =>
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first", "second"]),
|
||||
);
|
||||
await server.request("POST", `/agents/${id}/input`, {
|
||||
body: { type: "cancel" },
|
||||
});
|
||||
channel.complete();
|
||||
await vi.waitFor(async () =>
|
||||
expect((await server.request("GET", `/agents/${id}`)).body.status).toBe(
|
||||
"ready",
|
||||
),
|
||||
);
|
||||
expect(prompts).toHaveLength(2);
|
||||
const paused = (await server.request("GET", `/agents/${id}`)).body;
|
||||
expect(paused.queuePaused).toBe(true);
|
||||
expect(paused.queue.map((p: { text: string }) => p.text)).toEqual([
|
||||
"third",
|
||||
]);
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "continue" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first", "second", "third"]),
|
||||
);
|
||||
} finally {
|
||||
await server.request("POST", `/agents/${id}/stop`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
updateQueue,
|
||||
updateSession,
|
||||
promptInput,
|
||||
} from "../../../src/backend/agents/session-state.js";
|
||||
import type { AgentSession } from "../../../src/backend/agents/types.js";
|
||||
const session = () =>
|
||||
({ queue: [], attachments: [{ id: "image" }] }) as unknown as AgentSession;
|
||||
it("queues, edits and removes messages without replacing their IDs", () => {
|
||||
const s = session();
|
||||
updateQueue(s, { operation: "add", text: "first" });
|
||||
updateQueue(s, { operation: "add", text: "second" });
|
||||
const id = s.queue![0].id;
|
||||
updateQueue(s, {
|
||||
operation: "update",
|
||||
id,
|
||||
text: "edited",
|
||||
attachmentIds: ["image"],
|
||||
});
|
||||
expect(s.queue![0]).toEqual({ id, text: "edited", attachmentIds: ["image"] });
|
||||
updateQueue(s, { operation: "pause" });
|
||||
expect(s.queuePaused).toBe(true);
|
||||
updateQueue(s, { operation: "continue" });
|
||||
expect(s.queuePaused).toBe(false);
|
||||
updateQueue(s, { operation: "remove", id });
|
||||
expect(s.queue!.map((p) => p.text)).toEqual(["second"]);
|
||||
expect(() =>
|
||||
updateQueue(s, { operation: "update", id, text: "late edit" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects foreign attachments and caps queues", () => {
|
||||
const s = session();
|
||||
expect(() =>
|
||||
promptInput(s, { text: "x", attachmentIds: ["other-session"] }),
|
||||
).toThrow();
|
||||
for (let i = 0; i < 20; i++) updateQueue(s, { operation: "add", text: "x" });
|
||||
expect(() =>
|
||||
updateQueue(s, { operation: "add", text: "overflow" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("validates metadata before applying changes", () => {
|
||||
const s = session();
|
||||
updateSession(s, { title: "name", draft: "draft", archived: true });
|
||||
expect(s).toMatchObject({ title: "name", draft: "draft", archived: true });
|
||||
expect(() => updateSession(s, { title: "replacement", draft: 42 })).toThrow();
|
||||
expect(s.title).toBe("name");
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import {
|
||||
createAgentStore,
|
||||
EVENT_HISTORY,
|
||||
} from "../../../src/backend/agents/store.js";
|
||||
import { startServer, type TestServer } from "../helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const session = (id: string) => ({
|
||||
id,
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
agent: "pi" as const,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
executable: "pi",
|
||||
status: "stopped" as const,
|
||||
events: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
it("keeps saving a session whose history is far larger than one value", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
const store = await createAgentStore(server.mock.ctx);
|
||||
const id = "33333333-3333-3333-3333-333333333333";
|
||||
const s = session(id);
|
||||
await store.save(s);
|
||||
const chunk = "x".repeat(32000);
|
||||
let seq = 0;
|
||||
for (let batch = 0; batch < 30; batch++)
|
||||
await store.append(
|
||||
s,
|
||||
Array.from({ length: 100 }, () => ({
|
||||
seq: ++seq,
|
||||
kind: "tool" as const,
|
||||
text: chunk,
|
||||
})),
|
||||
);
|
||||
await store.save({
|
||||
...s,
|
||||
nativeId: "native-1",
|
||||
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
|
||||
});
|
||||
|
||||
const stored = await store.get("user-1", id);
|
||||
expect(stored).toMatchObject({
|
||||
nativeId: "native-1",
|
||||
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
|
||||
});
|
||||
const events = await store.events(id);
|
||||
expect(events).toHaveLength(EVENT_HISTORY);
|
||||
expect(events[0].seq).toBe(seq - EVENT_HISTORY + 1);
|
||||
expect(events.at(-1)?.seq).toBe(seq);
|
||||
expect(await store.events(id, seq - 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only returns a session to its owner and removes every row on delete", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
const store = await createAgentStore(server.mock.ctx);
|
||||
const id = "44444444-4444-4444-4444-444444444444";
|
||||
const s = session(id);
|
||||
await store.save({
|
||||
...s,
|
||||
queue: [{ id: "q1", text: "a", attachmentIds: [] }],
|
||||
});
|
||||
await store.append(s, [{ seq: 1, kind: "user", text: "hello" }]);
|
||||
|
||||
expect(await store.get("user-2", id)).toBeNull();
|
||||
expect(await store.list("user-2")).toEqual([]);
|
||||
expect((await store.list("user-1")).map((x) => x.id)).toEqual([id]);
|
||||
|
||||
await store.delete(id);
|
||||
expect(await store.get("user-1", id)).toBeNull();
|
||||
expect(await store.events(id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps prompts and drafts by UTF-8 bytes so they fit MySQL TEXT", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
const id = "55555555-5555-5555-5555-555555555555";
|
||||
await (await createAgentStore(server.mock.ctx)).save(session(id));
|
||||
const wide = "界".repeat(30000);
|
||||
const draft = await server.request("PATCH", `/agents/${id}`, {
|
||||
body: { draft: wide },
|
||||
});
|
||||
expect(draft.status).toBe(400);
|
||||
const queued = await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "add", text: wide },
|
||||
});
|
||||
expect(queued.status).toBe(400);
|
||||
const ascii = await server.request("PATCH", `/agents/${id}`, {
|
||||
body: { draft: "a".repeat(64000) },
|
||||
});
|
||||
expect(ascii.status).toBe(200);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, afterEach, expect, it } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync, execFileSync } from "node:child_process";
|
||||
import { REMOTE_WORKSPACE } from "../../../src/backend/agents/workspace.js";
|
||||
let home: string, cwd: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "termix-workspace-"));
|
||||
cwd = join(home, "repo");
|
||||
mkdirSync(cwd);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
function run(body: unknown) {
|
||||
const r = spawnSync(process.execPath, ["-e", REMOTE_WORKSPACE], {
|
||||
env: { ...process.env, HOME: home },
|
||||
input: JSON.stringify({ cwd, id: "test", body }),
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (r.status) throw Error(r.stderr);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
function git(...args: string[]) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
it("uploads an image without trusting the caller's MIME type and preserves exact bytes", () => {
|
||||
const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2]);
|
||||
const a = run({
|
||||
operation: "upload",
|
||||
name: "image.png",
|
||||
data: data.toString("base64"),
|
||||
});
|
||||
expect(a.mime).toBe("image/png");
|
||||
expect(readFileSync(a.path)).toEqual(data);
|
||||
expect(
|
||||
a.path.startsWith(
|
||||
join(home, ".local/state/termix-agents/test/attachments"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("rejects oversized files, directories and references outside the workspace", () => {
|
||||
writeFileSync(join(home, "outside"), "secret");
|
||||
symlinkSync(join(home, "outside"), join(cwd, "escape"));
|
||||
writeFileSync(join(cwd, "large"), Buffer.alloc(1024 * 1024 + 1));
|
||||
for (const path of ["../outside", "escape", "large", "."])
|
||||
expect(() => run({ operation: "reference", path })).toThrow();
|
||||
});
|
||||
it("shows staged, unstaged and untracked changes and creates an isolated branch", () => {
|
||||
git("init");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
mkdirSync(join(cwd, "nested"));
|
||||
writeFileSync(join(cwd, "nested", "deleted.txt"), "nested base\n");
|
||||
writeFileSync(join(cwd, "file.txt"), "base\n");
|
||||
git("add", ".");
|
||||
git("commit", "-m", "initial");
|
||||
writeFileSync(join(cwd, "file.txt"), "staged\n");
|
||||
git("add", ".");
|
||||
writeFileSync(join(cwd, "file.txt"), "working\n");
|
||||
writeFileSync(join(cwd, "new.txt"), "untracked");
|
||||
expect(run({ operation: "status" }).files).toContainEqual({
|
||||
status: "??",
|
||||
path: "new.txt",
|
||||
});
|
||||
const diff = run({ operation: "diff", path: "file.txt" }).diff;
|
||||
expect(diff).toContain("+working");
|
||||
expect(diff).toContain("+staged");
|
||||
expect(run({ operation: "diff", path: "new.txt" }).diff).toContain(
|
||||
"+untracked",
|
||||
);
|
||||
const wt = run({ operation: "worktree", branch: "termix/test" });
|
||||
expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("base\n");
|
||||
expect(readFileSync(join(cwd, "file.txt"), "utf8")).toBe("working\n");
|
||||
expect(() => run({ operation: "worktree", branch: "termix/test" })).toThrow();
|
||||
rmSync(join(cwd, "nested"), { recursive: true });
|
||||
expect(run({ operation: "diff", path: "nested/deleted.txt" }).diff).toContain(
|
||||
"-nested base",
|
||||
);
|
||||
rmSync(join(cwd, "file.txt"));
|
||||
expect(run({ operation: "diff", path: "file.txt" }).diff).toContain(
|
||||
"-staged",
|
||||
);
|
||||
});
|
||||
@@ -46,6 +46,9 @@ describe("ai adoption migration", () => {
|
||||
});
|
||||
|
||||
expect(tables(db.sqlite)).toEqual([
|
||||
"p_ai_agent_events",
|
||||
"p_ai_agent_queue",
|
||||
"p_ai_agent_sessions",
|
||||
"p_ai_conversations",
|
||||
"p_ai_messages",
|
||||
"p_ai_proposals",
|
||||
@@ -83,6 +86,9 @@ describe("ai adoption migration", () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tables(db.sqlite)).toEqual([
|
||||
"p_ai_agent_events",
|
||||
"p_ai_agent_queue",
|
||||
"p_ai_agent_sessions",
|
||||
"p_ai_conversations",
|
||||
"p_ai_messages",
|
||||
"p_ai_proposals",
|
||||
|
||||
@@ -17,6 +17,7 @@ const EXPECTED = [
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
"ai.agents",
|
||||
];
|
||||
|
||||
describe("ai permissions", () => {
|
||||
@@ -54,7 +55,9 @@ describe("ai permissions", () => {
|
||||
it("keeps the fresh-install defaults core used to seed", () => {
|
||||
for (const permission of manifest.manifest?.contributes?.permissions ??
|
||||
[]) {
|
||||
expect(permission.defaultRoles).toEqual(["user"]);
|
||||
expect(permission.defaultRoles).toEqual([
|
||||
permission.name === "agents" ? "admin" : "user",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { TabProps } from "@termix/plugin-sdk/frontend";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
vi.mock("../../src/frontend/app-ref", () => ({
|
||||
aiApp: () => ({ api: mocks, fetch: mocks.fetch }),
|
||||
}));
|
||||
vi.mock("../../src/frontend/ai-api", () => ({
|
||||
getAiProviders: async () => [],
|
||||
}));
|
||||
vi.mock("react-i18next", async (original) => ({
|
||||
...(await original<object>()),
|
||||
useTranslation: () => ({ t: mocks.t }),
|
||||
}));
|
||||
vi.mock("../../src/frontend/AiMessage", () => ({ AiMessage: () => null }));
|
||||
import { AgentPanel } from "../../src/frontend/agents/AgentPanel";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
mocks.post.mockResolvedValue({ data: {} });
|
||||
mocks.patch.mockResolvedValue({ data: {} });
|
||||
mocks.fetch.mockImplementation(async () => new Response("", { status: 200 }));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
async function panel(status = "ready") {
|
||||
const session = {
|
||||
id: "test",
|
||||
hostId: 2,
|
||||
agent: "pi",
|
||||
model: "test-model",
|
||||
cwd: "/tmp",
|
||||
status,
|
||||
events: [],
|
||||
};
|
||||
mocks.get.mockImplementation(async (path: string) => ({
|
||||
data: path === "/agents" ? { sessions: [session] } : session,
|
||||
}));
|
||||
render(
|
||||
<AgentPanel
|
||||
{...({
|
||||
host: { id: "2", name: "test", ip: "127.0.0.1", port: 22 },
|
||||
} as TabProps)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Pi · test-model"));
|
||||
const textarea = await screen.findByRole("textbox", {
|
||||
name: "agents.prompt",
|
||||
});
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalled());
|
||||
fireEvent.change(textarea, { target: { value: "hello" } });
|
||||
return textarea;
|
||||
}
|
||||
|
||||
it("sends on Enter and clears the draft", async () => {
|
||||
const textarea = await panel();
|
||||
expect(fireEvent.keyDown(textarea, { key: "Enter" })).toBe(false);
|
||||
await waitFor(() =>
|
||||
expect(mocks.post).toHaveBeenCalledWith("/agents/test/input", {
|
||||
type: "prompt",
|
||||
text: "hello",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect((textarea as HTMLTextAreaElement).value).toBe(""));
|
||||
expect(mocks.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves Shift+Enter, composition Enter, legacy IME keys and key repeats", async () => {
|
||||
const textarea = await panel();
|
||||
expect(fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })).toBe(
|
||||
true,
|
||||
);
|
||||
fireEvent.compositionStart(textarea);
|
||||
expect(fireEvent.keyDown(textarea, { key: "Enter" })).toBe(true);
|
||||
fireEvent.compositionEnd(textarea);
|
||||
expect(fireEvent.keyDown(textarea, { key: "Enter", isComposing: true })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 })).toBe(
|
||||
true,
|
||||
);
|
||||
fireEvent.keyDown(textarea, { key: "Enter", repeat: true });
|
||||
expect(mocks.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit whitespace via Enter or direct form submission", async () => {
|
||||
const textarea = await panel();
|
||||
fireEvent.change(textarea, { target: { value: " \n " } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
fireEvent.submit(textarea.closest("form")!);
|
||||
expect(mocks.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts only the focused panel and leaves the draft intact", async () => {
|
||||
const textarea = await panel("running");
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
fireEvent.compositionStart(textarea);
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
fireEvent.compositionEnd(textarea);
|
||||
fireEvent.keyDown(textarea, { key: "Escape", isComposing: true });
|
||||
fireEvent.keyDown(textarea, { key: "Escape", keyCode: 229 });
|
||||
fireEvent.keyDown(textarea, { key: "Escape", repeat: true });
|
||||
expect(mocks.post).not.toHaveBeenCalled();
|
||||
expect(fireEvent.keyDown(textarea, { key: "Escape" })).toBe(false);
|
||||
await waitFor(() =>
|
||||
expect(mocks.post).toHaveBeenCalledWith("/agents/test/input", {
|
||||
type: "cancel",
|
||||
}),
|
||||
);
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("hello");
|
||||
});
|
||||
|
||||
it("ignores Escape for an idle session", async () => {
|
||||
const textarea = await panel();
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect(mocks.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the draft and permits retry when sending fails", async () => {
|
||||
const textarea = await panel();
|
||||
mocks.post.mockRejectedValueOnce(new Error("Connection lost"));
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toBe("Connection lost"),
|
||||
);
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("hello");
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
await waitFor(() => expect((textarea as HTMLTextAreaElement).value).toBe(""));
|
||||
expect(mocks.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -64,6 +64,8 @@ describe(`${manifest.id} activate`, () => {
|
||||
|
||||
it("adds the assistant tab once AI is on for the user", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
await waitFor(() => expect(rendered!.registered.tabs()).toEqual(["ai"]));
|
||||
await waitFor(() =>
|
||||
expect(rendered!.registered.tabs()).toEqual(["ai", "ai-agents"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useAgentStream } from "../../src/frontend/agents/useAgentStream";
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), fetch: vi.fn() }));
|
||||
vi.mock("../../src/frontend/app-ref", () => ({
|
||||
aiApp: () => ({ api, fetch: api.fetch }),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
it("restores a snapshot after an EOF and resumes after its last sequence", async () => {
|
||||
api.get
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: "x",
|
||||
status: "running",
|
||||
events: [{ seq: 1, kind: "user", text: "hi" }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
id: "x",
|
||||
status: "running",
|
||||
events: [
|
||||
{ seq: 1, kind: "user", text: "hi" },
|
||||
{ seq: 2, kind: "text", text: "recovered" },
|
||||
],
|
||||
},
|
||||
});
|
||||
api.fetch.mockResolvedValueOnce(new Response(""));
|
||||
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
||||
api.fetch.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(c) {
|
||||
stream = c;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const snapshot = vi.fn(),
|
||||
event = vi.fn();
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useAgentStream("x", 0, snapshot, event),
|
||||
);
|
||||
await waitFor(() => expect(result.current).toBe("reconnecting"));
|
||||
await waitFor(() => expect(api.fetch).toHaveBeenCalledTimes(2), {
|
||||
timeout: 2500,
|
||||
});
|
||||
expect(api.fetch.mock.calls[1][0]).toBe("agents/x/events?after=2");
|
||||
await act(async () => {
|
||||
stream.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"seq":2,"kind":"text","text":"duplicate"}\n\ndata: {"seq":3,"kind":"text","text":"new"}\n\n',
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
expect(event).toHaveBeenCalledWith({ seq: 3, kind: "text", text: "new" });
|
||||
expect(snapshot.mock.lastCall?.[0].events[1].text).toBe("recovered");
|
||||
unmount();
|
||||
stream.close();
|
||||
});
|
||||
it("does not reconnect an inaccessible session", async () => {
|
||||
api.get.mockRejectedValue({ response: { status: 403 } });
|
||||
const { result } = renderHook(() => useAgentStream("x", 0, vi.fn(), vi.fn()));
|
||||
await waitFor(() => expect(result.current).toBe("unavailable"));
|
||||
expect(api.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -181,7 +181,7 @@ describe("termix id panel", () => {
|
||||
expect(screen.getByText(IDENTITY.resolverUrl)).toBeTruthy();
|
||||
expect(screen.getByText("Laptop")).toBeTruthy();
|
||||
expect(screen.getByText(KEY.publicKey)).toBeTruthy();
|
||||
expect(api.get).toHaveBeenCalledWith("/credentials");
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith("/credentials"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -905,6 +905,54 @@ RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
|
||||
-- ==== plugin ai 0002_provider_sync.sql ====
|
||||
ALTER TABLE `p_ai_providers` ADD COLUMN `sync_id` varchar(255) UNIQUE;
|
||||
|
||||
-- ==== plugin ai 0003_agent_sessions.sql ====
|
||||
-- ai 0003: agent_sessions
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_sessions` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL UNIQUE,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`provider_id` int NOT NULL,
|
||||
`agent` varchar(32) NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`cwd` text NOT NULL,
|
||||
`executable` text NOT NULL,
|
||||
`native_id` text,
|
||||
`title` text,
|
||||
`archived` boolean NOT NULL DEFAULT false,
|
||||
`queue_paused` boolean NOT NULL DEFAULT false,
|
||||
`draft` text,
|
||||
`attachments` text NOT NULL DEFAULT ('[]'),
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_ai_agent_sessions_user` ON `p_ai_agent_sessions` (`user_id`);
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_events` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`seq` int NOT NULL,
|
||||
`kind` varchar(16) NOT NULL,
|
||||
`text` text NOT NULL,
|
||||
`request_id` text,
|
||||
`choices` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX `idx_ai_agent_events_session_seq` ON `p_ai_agent_events` (`session_id`, `seq`);
|
||||
CREATE TABLE IF NOT EXISTS `p_ai_agent_queue` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`session_id` varchar(36) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`prompt_id` varchar(36) NOT NULL,
|
||||
`position` int NOT NULL,
|
||||
`text` text NOT NULL,
|
||||
`attachment_ids` text NOT NULL DEFAULT ('[]'),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX `idx_ai_agent_queue_session` ON `p_ai_agent_queue` (`session_id`, `position`);
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -953,6 +953,54 @@ ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
|
||||
-- ==== plugin ai 0002_provider_sync.sql ====
|
||||
ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" varchar(255) UNIQUE;
|
||||
|
||||
-- ==== plugin ai 0003_agent_sessions.sql ====
|
||||
-- ai 0003: agent_sessions
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL UNIQUE,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"agent" varchar(32) NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"cwd" text NOT NULL,
|
||||
"executable" text NOT NULL,
|
||||
"native_id" text,
|
||||
"title" text,
|
||||
"archived" boolean NOT NULL DEFAULT false,
|
||||
"queue_paused" boolean NOT NULL DEFAULT false,
|
||||
"draft" text,
|
||||
"attachments" text NOT NULL DEFAULT '[]',
|
||||
"updated_at" text NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_sessions_user" ON "p_ai_agent_sessions" ("user_id");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_events" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"request_id" text,
|
||||
"choices" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_agent_events_session_seq" ON "p_ai_agent_events" ("session_id", "seq");
|
||||
CREATE TABLE IF NOT EXISTS "p_ai_agent_queue" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"session_id" varchar(36) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"prompt_id" varchar(36) NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"attachment_ids" text NOT NULL DEFAULT '[]',
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ai_agent_queue_session" ON "p_ai_agent_queue" ("session_id", "position");
|
||||
|
||||
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||
-- alerts 0001: create_alerts
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -378,6 +378,7 @@ describe("shipped manifests keep their permission ids", () => {
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
"ai.agents",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user