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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ These add a page to magpie's window, in the app and in `magpie web`, under Plugi
| [agent-office](packages/agent-office) | What are my agents doing: live sessions as workers in a voxel office (3D/2D) or a list, with status, current step and resume commands | no `magpie.read`; its host part reads the local session files of Claude Code, Codex, dsh, Gemini CLI, Qwen Code, Pi, omp, Qoder, WorkBuddy and CodeBuddy, including prompts, replies and tool commands (`text:false` keeps statuses and tool names only) |
| [fingerprint](packages/fingerprint) | Numeric response fingerprints to investigate suspected model substitution across providers, with shared challenges, candidate rankings, local evidence and JSON export | none (its local service calls magpie’s gateway; sampling uses provider quota) |
| [chat](packages/chat) | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magiczerowxy](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) |
| [agent-console](packages/agent-console) | Drive the Agent CLIs on this computer (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh) with a timeline, Git changes, files and a terminal, by [qingnian108](https://github.com/qingnian108). Has a host part: a gate and a daemon on 127.0.0.1, started when the page opens | none (its daemon runs the Agent CLIs you pick, in the projects you open) |

```sh
magpie plugin add @magpie-community/page-<name>
Expand Down
4 changes: 4 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[test]
# packages/agent-console has its own vitest suite with npm dependencies (npm ci
# in that folder, then npm test); the root `bun test` doesn't install them
pathIgnorePatterns = ["packages/agent-console/test/**", "packages/agent-console/web/**"]
10 changes: 10 additions & 0 deletions packages/agent-console/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.ts text eol=lf
*.tsx text eol=lf
*.mjs text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.json text eol=lf
*.md text eol=lf
app/** -text
daemon/dist/** -text
6 changes: 6 additions & 0 deletions packages/agent-console/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
_tmp/
node_modules/
*.log
.DS_Store
page/session.js
*.tgz
21 changes: 21 additions & 0 deletions packages/agent-console/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 qingnian108

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
188 changes: 188 additions & 0 deletions packages/agent-console/README.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions packages/agent-console/THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Third-Party Notices

## Design references

The persistent-session architecture, event protocol and UI layout of this plugin were designed
with reference to the following open-source projects (only design ideas and protocol shapes were
referenced; no source code was copied):

- **Paseo** (https://github.com/getpaseo/paseo)
— persistent daemon + WebSocket event push, and the provider session abstraction.
- **T3 Code** (https://github.com/pingdotgg/t3code, MIT)
— timeline item shapes (tool groups, permission cards, plan cards) and the three-column workbench layout.

## npm dependencies and their licenses

Installed with the plugin (`dependencies`, and `node-pty` as an `optionalDependency`):

| Package | Version | License |
|---|---|---|
| @agentclientprotocol/sdk | 2.1.0 | Apache-2.0 |
| ignore | 7.0.14 | MIT |
| node-pty (optional) | 1.2.0-beta.15 | MIT |
| ws | 8.22.0 | MIT |
| zod | 4.6.7 | MIT |

Bundled into `app/app.js` (`devDependencies`: not installed for users, but their code ships inside the bundle):

| Package | Version | License |
|---|---|---|
| @tanstack/react-virtual | 3.14.24 | MIT |
| @xterm/addon-fit | 0.11.0 | MIT |
| @xterm/xterm | 6.0.0 | MIT |
| clsx | 2.1.1 | MIT |
| dompurify | 3.4.17 | MPL-2.0 OR Apache-2.0 |
| highlight.js | 11.12.0 | BSD-3-Clause |
| lucide-react | 1.64.0 | ISC |
| marked | 18.1.2 | MIT |
| react | 19.3.1 | MIT |
| react-dom | 19.3.1 | MIT |
| zustand | 5.0.16 | MIT |

Build and test tools (`devDependencies`; nothing from them ships):
typescript (Apache-2.0), esbuild (MIT), vitest (MIT), playwright-core (Apache-2.0),
@types/* (MIT).

The Codex app-server JSON-RPC schema files (`daemon/src/providers/codex/schema/`) are generated
locally by `codex app-server generate-json-schema`. They describe the OpenAI Codex CLI interface
and are used under the Codex license; the plugin uses them only for protocol integration.
144 changes: 144 additions & 0 deletions packages/agent-console/agent-console.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// What the repository's root `bun test` checks for Agent Console, with no npm
// install: the package's shape and dependency split, that the magpie page reads
// nothing and reaches no network but its own navigation, that the bundles load
// only declared packages, and the gate's Origin/Host/key rules. The full suite
// (daemon, lifeline, PR #535 HTTP probes, terminal fallback) is vitest with npm
// dependencies: `npm ci --workspaces=false && npm test` in this folder; the root
// bunfig.toml keeps `bun test` out of it.
import { expect, test } from "bun:test"
import { existsSync, readFileSync } from "node:fs"
import { builtinModules } from "node:module"
import { request } from "node:http"
import { createServer } from "node:net"
import { join } from "node:path"
import { pathToFileURL } from "node:url"

const dir = import.meta.dir
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
const read = (f) => readFileSync(join(dir, f), "utf8")

const RUNTIME = ["@agentclientprotocol/sdk", "ignore", "ws", "zod"]
const BUNDLED = ["@tanstack/react-virtual", "@xterm/addon-fit", "@xterm/xterm", "clsx", "dompurify", "highlight.js", "lucide-react", "marked", "react", "react-dom", "zustand"]

test("package.json: name, page, permissions and files", () => {
expect(pkg.name).toBe("@magpie-community/page-agent-console")
expect(pkg.type).toBe("module")
expect(pkg.main).toBe("./index.mjs")
expect(pkg.magpie.page.title).toBe("Agent Console")
for (const f of [pkg.magpie.page.file, pkg.magpie.page.icon]) expect(existsSync(join(dir, f))).toBe(true)
expect(pkg.magpie.icon.startsWith("data:image/svg+xml;base64,")).toBe(true)
// the page makes no magpie.read
expect(pkg.magpie.permissions).toEqual([])
// the tarball: no tests, no sources, never the per-run session.js
expect(pkg.files).not.toContain("page")
expect(pkg.files).toContain("page/index.html")
expect(pkg.files).toContain("page/boot.js")
for (const f of pkg.files) expect(f.startsWith("test") || f.startsWith("web") || f === "daemon/src").toBe(false)
})

test("dependencies: only what runs, every version pinned, lockfile in step", () => {
expect(Object.keys(pkg.dependencies).sort()).toEqual(RUNTIME)
expect(Object.keys(pkg.optionalDependencies)).toEqual(["node-pty"])
for (const name of BUNDLED) expect(pkg.devDependencies[name]).toBeDefined()
const lock = JSON.parse(read("package-lock.json"))
expect(lock.name).toBe(pkg.name)
expect(lock.version).toBe(pkg.version)
for (const kind of ["dependencies", "optionalDependencies", "devDependencies"]) {
for (const [name, version] of Object.entries(pkg[kind])) {
expect(version).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/)
expect(lock.packages[`node_modules/${name}`]?.version).toBe(version)
expect(lock.packages[""][kind][name]).toBe(version)
}
}
for (const name of BUNDLED) expect(lock.packages[`node_modules/${name}`].dev).toBe(true)
// Claude Code is the user's own claude CLI: no Anthropic package is installed, locked or bundled
const named = [...Object.keys(lock.packages), ...["dependencies", "optionalDependencies", "devDependencies"].flatMap((k) => Object.keys(pkg[k] ?? {}))]
expect(named.filter((n) => n.includes("@anthropic-ai/"))).toEqual([])
// (the one @anthropic-ai/ name left in the bundle is the install hint for the user's own claude)
expect(read("daemon/dist/main.js").match(/@anthropic-ai\/[\w-]+/g)?.filter((n) => n !== "@anthropic-ai/claude-code") ?? []).toEqual([])
})

test("the daemon bundle and the host part import only builtins and declared packages", () => {
const builtins = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]))
const allowed = new Set([...RUNTIME, "node-pty"])
const specs = (src) => [...src.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*)["']([^"'./][^"']*)["']/g)].map((m) => m[1])
const sources = ["daemon/dist/main.js", "index.mjs", "lib/gate.mjs", "lib/pathscan.mjs"].map(read)
const seen = new Set(sources.flatMap(specs))
expect(seen.size).toBeGreaterThan(5)
for (const s of seen) {
const name = s.startsWith("@") ? s.split("/").slice(0, 2).join("/") : s.split("/")[0]
if (!builtins.has(s)) expect(allowed.has(name) ? name : `undeclared ${s}`).toBe(name)
}
// the UI packages are inside app/app.js, never loaded from node_modules
for (const name of BUNDLED) expect(seen.has(name)).toBe(false)
// node-pty is loaded lazily, so a missing or broken binary can only cost the terminal
expect(read("daemon/dist/main.js")).not.toMatch(/from\s*["']node-pty["']/)
})

test("the page reads nothing from magpie and reaches no network", () => {
const html = read("page/index.html")
// the code, without its comments (they name the gate's URL)
const boot = read("page/boot.js").replace(/^\s*\/\/.*$/gm, "").replace(/\/\*.*?\*\//g, "")
const scripts = [...html.matchAll(/<script[^>]*src="([^"]+)"/g)].map((m) => m[1])
expect(scripts).toEqual(["_magpie/bridge.js", "session.js", "boot.js"])
expect(html).not.toMatch(/<script(?![^>]*src=)[^>]*>/)
expect(html).not.toMatch(/https?:\/\//)
expect(boot).not.toMatch(/magpie\??\.read\s*\(/)
expect(boot).not.toMatch(/\.options\??\.(get|set)\b/)
for (const api of ["fetch(", "XMLHttpRequest", "WebSocket", "EventSource", "sendBeacon", "importScripts"]) expect(boot.includes(api)).toBe(false)
// its one way out: navigating its own frame to the gate on 127.0.0.1
expect([...boot.matchAll(/https?:\/\/[^"'`]+/g)].map((m) => m[0])).toEqual(["http://127.0.0.1:"])
expect(boot).toContain('location.replace("http://127.0.0.1:" + session.port + "/open?"')
})

test("the host part is an OpenCode v1 server, as magpie loads a page's host part", async () => {
const mod = await import(pathToFileURL(join(dir, "index.mjs")).href)
expect(mod.default.id).toBe("agent-console")
expect(typeof mod.default.server).toBe("function")
})

const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer()
s.once("error", reject)
s.listen(0, "127.0.0.1", () => {
const { port } = s.address()
s.close(() => resolve(port))
})
})

const get = (port, path, headers = {}) =>
new Promise((resolve, reject) => {
const r = request({ host: "127.0.0.1", port, path, headers }, (res) => {
let body = ""
res.on("data", (c) => (body += c))
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body }))
})
r.on("error", reject)
r.end()
})

test("the gate: Host checked, any Origin refused, the token only in the # fragment", async () => {
const { createGate } = await import(pathToFileURL(join(dir, "lib", "gate.mjs")).href)
const port = await freePort()
const key = "c".repeat(64)
const token = "a".repeat(64)
let opened = 0
const gate = createGate({ port, key, token, ensureDaemon: async () => (opened++, { port: 45678 }) })
await gate.listen()
try {
expect((await get(port, `/open?k=${key}`, { host: `evil.example:${port}` })).status).toBe(421)
expect((await get(port, `/open?k=${key}`, { origin: "null" })).status).toBe(403)
expect((await get(port, `/open?k=${key}`, { origin: "https://evil.example" })).status).toBe(403)
expect((await get(port, "/open?k=wrong")).status).toBe(403)
expect((await get(port, `/open?k=${token}`)).status).toBe(403)
expect(opened).toBe(0)
const ok = await get(port, `/open?k=${key}&theme=dark`)
expect(ok.status).toBe(302)
expect(ok.headers.location).toBe(`http://127.0.0.1:45678/?theme=dark#t=${token}`)
expect(ok.headers["access-control-allow-origin"]).toBeUndefined()
expect(ok.body).not.toContain(token)
} finally {
await gate.close()
}
})
37 changes: 37 additions & 0 deletions packages/agent-console/app/app.css

Large diffs are not rendered by default.

202 changes: 202 additions & 0 deletions packages/agent-console/app/app.js

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/agent-console/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Agent Console</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="root"></div>
<script src="app.js"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions packages/agent-console/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading