rtrt-agent 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +45 -10
- package/README.md +45 -10
- package/THIRD_PARTY_NOTICES/INDEX.md +24 -11
- package/THIRD_PARTY_NOTICES/option-ext@0.2.0/SOURCE.crate +0 -0
- package/package.json +43 -12
- package/server.js +98 -0
- package/tui/index.js +1 -0
- package/tui/rtrt-statusline-core.d.mts +205 -0
- package/tui/rtrt-statusline-core.mjs +1145 -0
- package/tui/v2/native-runtime.mjs +81 -0
- package/tui/v2/native-state.mjs +66 -0
- package/tui/v2/rtrt-statusline.tsx +116 -0
- package/v2/permissions.js +119 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { isAbsolute } from "node:path"
|
|
3
|
+
import { createStatuslineController, runStatusline } from "../rtrt-statusline-core.mjs"
|
|
4
|
+
|
|
5
|
+
/** @typedef {import("@opencode/plugin/tui").Plugin.Context} Context */
|
|
6
|
+
/** @typedef {import("@opencode/plugin/tui/context").SlotMap["prompt.footer.status"]} FooterInput */
|
|
7
|
+
|
|
8
|
+
/** @type {readonly import("@opencode/client").OpenCodeEvent["type"][]} */
|
|
9
|
+
export const NATIVE_REFRESH_EVENTS = Object.freeze([
|
|
10
|
+
"session.created", "session.deleted", "session.renamed", "session.moved",
|
|
11
|
+
"session.model.selected", "session.status", "session.idle",
|
|
12
|
+
"session.step.ended", "session.step.failed",
|
|
13
|
+
"session.usage.updated",
|
|
14
|
+
"session.compaction.ended", "session.compaction.failed",
|
|
15
|
+
"model.updated", "provider.updated",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Context} ctx
|
|
20
|
+
* @param {{readonly sessionID: () => string, readonly refresh: () => void, readonly dispose: () => void}} entry
|
|
21
|
+
* @param {(input: FooterInput) => import("@opentui/solid").JSX.Element} render
|
|
22
|
+
*/
|
|
23
|
+
export function registerNativeStatusline(ctx, entry, render) {
|
|
24
|
+
/** @type {Array<() => void>} */
|
|
25
|
+
const disposers = []
|
|
26
|
+
let disposed = false
|
|
27
|
+
const cleanup = () => {
|
|
28
|
+
if (disposed) return
|
|
29
|
+
disposed = true
|
|
30
|
+
/** @type {unknown[]} */
|
|
31
|
+
const errors = []
|
|
32
|
+
for (const dispose of [entry.dispose, ...disposers.reverse()]) {
|
|
33
|
+
try { dispose() } catch (error) { errors.push(error) }
|
|
34
|
+
}
|
|
35
|
+
if (errors.length) throw new AggregateError(errors, "Native statusline cleanup failed")
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
for (const name of NATIVE_REFRESH_EVENTS) {
|
|
39
|
+
disposers.push(ctx.data.on(name, (event) => {
|
|
40
|
+
if (disposed) return
|
|
41
|
+
if ("sessionID" in event.data && event.data.sessionID !== entry.sessionID()) return
|
|
42
|
+
entry.refresh()
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
45
|
+
disposers.push(ctx.ui.slot({ append: "prompt.footer.status", render }))
|
|
46
|
+
return cleanup
|
|
47
|
+
} catch (error) {
|
|
48
|
+
try { cleanup() } catch (cleanupError) {
|
|
49
|
+
throw new AggregateError([error, cleanupError], "Native statusline registration failed")
|
|
50
|
+
}
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The existing controller serializes refreshes and owns subprocess aborts.
|
|
57
|
+
* The runner keeps its 1.5s total deadline, 64KiB cap and shell:false argv.
|
|
58
|
+
* An explicit bin must be absolute and never falls back after failure.
|
|
59
|
+
* When omitted, the optional CLI uses RTRT_BIN then PATH's rtrt; if neither
|
|
60
|
+
* works, the shared view shows unavailable rather than inventing CLI data.
|
|
61
|
+
* @param {Readonly<Record<string, unknown>>} options
|
|
62
|
+
* @param {(state: import("../rtrt-statusline-core.mjs").StatuslineState) => void} onState
|
|
63
|
+
* @param {typeof spawn} [spawnImpl]
|
|
64
|
+
*/
|
|
65
|
+
export function createNativeController(options, onState, spawnImpl = spawn) {
|
|
66
|
+
const explicit = Object.hasOwn(options, "bin")
|
|
67
|
+
const bin = typeof options["bin"] === "string" ? options["bin"] : undefined
|
|
68
|
+
/** @type {(binary: string, args: readonly string[], settings: import("node:child_process").SpawnOptions) => import("node:child_process").ChildProcess | undefined} */
|
|
69
|
+
const spawnCandidate = (binary, args, settings) => {
|
|
70
|
+
// Keep the shared legacy runner unchanged, but deny its fallback candidates.
|
|
71
|
+
if (explicit && binary !== bin) return undefined
|
|
72
|
+
return spawnImpl(binary, args, settings)
|
|
73
|
+
}
|
|
74
|
+
return createStatuslineController({
|
|
75
|
+
load: (context, signal) => {
|
|
76
|
+
if (explicit && (bin === undefined || !isAbsolute(bin))) return undefined
|
|
77
|
+
return runStatusline({ ...context, bin, signal, spawnImpl: spawnCandidate })
|
|
78
|
+
},
|
|
79
|
+
onState,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { deriveSessionEconomics } from "../rtrt-statusline-core.mjs"
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("@opencode/plugin/tui").Plugin.Context} Context */
|
|
4
|
+
|
|
5
|
+
/** @param {never} value @returns {never} */
|
|
6
|
+
function assertNever(value) {
|
|
7
|
+
throw new TypeError(`Unsupported native route: ${String(value)}`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Context} ctx
|
|
12
|
+
* @param {string} [slotSessionID]
|
|
13
|
+
*/
|
|
14
|
+
export function activeSessionID(ctx, slotSessionID) {
|
|
15
|
+
if (slotSessionID !== undefined) return slotSessionID
|
|
16
|
+
const route = ctx.ui.router.current()
|
|
17
|
+
switch (route.type) {
|
|
18
|
+
case "session": return route.sessionID
|
|
19
|
+
case "home":
|
|
20
|
+
case "plugin": return ""
|
|
21
|
+
default: return assertNever(route)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Translate only documented 2.0.20 values; never use aggregate session tokens
|
|
27
|
+
* as context usage, message costs as session spend, or pricing as quota.
|
|
28
|
+
* @param {Context} ctx
|
|
29
|
+
* @param {string} sessionID
|
|
30
|
+
* @param {ReturnType<Context["ui"]["model"]["current"]>} [selected]
|
|
31
|
+
* @returns {{ readonly cwd: string, readonly economics: import("../rtrt-statusline-core.mjs").StatuslineEconomics }}
|
|
32
|
+
*/
|
|
33
|
+
export function nativeSnapshot(ctx, sessionID, selected = ctx.ui.model.current()) {
|
|
34
|
+
const session = sessionID ? ctx.data.session.get(sessionID) : undefined
|
|
35
|
+
const location = session?.location ?? ctx.location ?? ctx.data.location.default()
|
|
36
|
+
const models = ctx.data.location.model.list(location) ?? []
|
|
37
|
+
const providers = Array.from(new Set(models.map((model) => model.providerID)), (id) => ({
|
|
38
|
+
id,
|
|
39
|
+
models: Object.fromEntries(models.filter((model) => model.providerID === id).map((model) => [model.id, model])),
|
|
40
|
+
}))
|
|
41
|
+
const model = selected ? { id: selected.modelID, providerID: selected.providerID } : session?.model
|
|
42
|
+
const messages = session ? ctx.data.session.message.list(sessionID) : []
|
|
43
|
+
const economics = deriveSessionEconomics({
|
|
44
|
+
session: {
|
|
45
|
+
...(session ? { cost: session.cost } : {}),
|
|
46
|
+
...(model ? { model } : {}),
|
|
47
|
+
},
|
|
48
|
+
messages: messages.filter((message) => message.type === "assistant").map((message) => ({
|
|
49
|
+
role: "assistant",
|
|
50
|
+
providerID: message.model.providerID,
|
|
51
|
+
modelID: message.model.id,
|
|
52
|
+
time: message.time,
|
|
53
|
+
...(message.tokens ? { tokens: message.tokens } : {}),
|
|
54
|
+
...(message.error ? { error: message.error } : {}),
|
|
55
|
+
})),
|
|
56
|
+
providers,
|
|
57
|
+
status: { type: session && ctx.data.session.status(sessionID) === "running" ? "busy" : "idle" },
|
|
58
|
+
})
|
|
59
|
+
return {
|
|
60
|
+
cwd: location.directory,
|
|
61
|
+
economics: {
|
|
62
|
+
...economics,
|
|
63
|
+
status: session ? economics.status : { text: "UNKNOWN", tone: "muted" },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { Plugin } from "@opencode/plugin/tui"
|
|
3
|
+
import type { SlotMap } from "@opencode/plugin/tui/context"
|
|
4
|
+
import type { BoxRenderable } from "@opentui/core"
|
|
5
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
6
|
+
import { createEffect, createMemo, createSignal, For, onMount, untrack } from "solid-js"
|
|
7
|
+
import {
|
|
8
|
+
createStatuslineViewModel,
|
|
9
|
+
initialStatuslineState,
|
|
10
|
+
type StatuslineState,
|
|
11
|
+
type StatuslineViewPart,
|
|
12
|
+
} from "../rtrt-statusline-core.mjs"
|
|
13
|
+
import { activeSessionID, nativeSnapshot } from "./native-state.mjs"
|
|
14
|
+
import { createNativeController, registerNativeStatusline } from "./native-runtime.mjs"
|
|
15
|
+
|
|
16
|
+
// Preserve the legacy footer's two-cell gutters, inherited terminal typography,
|
|
17
|
+
// truncation and responsive view model. All colors come from the native theme.
|
|
18
|
+
const GUTTER = 2
|
|
19
|
+
|
|
20
|
+
function partColor(theme: Plugin.Context["theme"], part: StatuslineViewPart) {
|
|
21
|
+
switch (part.role) {
|
|
22
|
+
case "separator": return theme.border.base
|
|
23
|
+
case "label": return theme.text.muted
|
|
24
|
+
case "badge":
|
|
25
|
+
case "value": return {
|
|
26
|
+
accent: theme.hue.accent[600],
|
|
27
|
+
good: theme.text.feedback.success.base,
|
|
28
|
+
warn: theme.text.feedback.warning.base,
|
|
29
|
+
bad: theme.text.feedback.error.base,
|
|
30
|
+
muted: theme.text.muted,
|
|
31
|
+
}[part.tone]
|
|
32
|
+
default: {
|
|
33
|
+
const exhaustive: never = part.role
|
|
34
|
+
return exhaustive
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Entry = {
|
|
40
|
+
readonly state: () => StatuslineState
|
|
41
|
+
readonly revision: () => number
|
|
42
|
+
readonly controller: ReturnType<typeof createNativeController>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function NativeLine(props: {
|
|
46
|
+
readonly ctx: Plugin.Context
|
|
47
|
+
readonly input: SlotMap["prompt.footer.status"]
|
|
48
|
+
readonly entry: Entry
|
|
49
|
+
}) {
|
|
50
|
+
const dimensions = useTerminalDimensions()
|
|
51
|
+
const [measuredWidth, setMeasuredWidth] = createSignal<number>()
|
|
52
|
+
const width = createMemo(() => Math.max(1, (measuredWidth() ?? dimensions().width) - GUTTER * 2))
|
|
53
|
+
const sessionID = () => activeSessionID(props.ctx, props.input.sessionID)
|
|
54
|
+
const snapshot = createMemo(() => {
|
|
55
|
+
props.entry.revision()
|
|
56
|
+
const session = sessionID()
|
|
57
|
+
const selected = props.ctx.ui.model.current()
|
|
58
|
+
// Stream deltas never subscribe the footer to every token.
|
|
59
|
+
return untrack(() => nativeSnapshot(props.ctx, session, selected))
|
|
60
|
+
})
|
|
61
|
+
const view = createMemo(() => createStatuslineViewModel(props.entry.state(), {
|
|
62
|
+
width: width(), economics: snapshot().economics,
|
|
63
|
+
}))
|
|
64
|
+
createEffect(() => {
|
|
65
|
+
props.entry.controller.setContext({
|
|
66
|
+
cwd: snapshot().cwd,
|
|
67
|
+
session: sessionID(),
|
|
68
|
+
width: width(),
|
|
69
|
+
model: snapshot().economics.modelRef ?? "",
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
onMount(() => props.entry.controller.start())
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<box
|
|
76
|
+
width="100%"
|
|
77
|
+
height={view().rows.length}
|
|
78
|
+
flexDirection="column"
|
|
79
|
+
flexShrink={0}
|
|
80
|
+
paddingLeft={GUTTER}
|
|
81
|
+
paddingRight={GUTTER}
|
|
82
|
+
overflow="hidden"
|
|
83
|
+
onSizeChange={function (this: BoxRenderable) { setMeasuredWidth(this.width) }}
|
|
84
|
+
>
|
|
85
|
+
<For each={view().rows}>
|
|
86
|
+
{(row) => (
|
|
87
|
+
<text fg={props.ctx.theme.text.muted} wrapMode="none" truncate>
|
|
88
|
+
<For each={row.parts}>
|
|
89
|
+
{(part) => <span style={{ fg: partColor(props.ctx.theme, part) }}>{part.text}</span>}
|
|
90
|
+
</For>
|
|
91
|
+
</text>
|
|
92
|
+
)}
|
|
93
|
+
</For>
|
|
94
|
+
</box>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default {
|
|
99
|
+
id: "rtrt-statusline",
|
|
100
|
+
setup(ctx) {
|
|
101
|
+
if (ctx.options["enabled"] === false) return
|
|
102
|
+
const [state, setState] = createSignal(initialStatuslineState())
|
|
103
|
+
const [revision, setRevision] = createSignal(0)
|
|
104
|
+
const refreshEconomics = () => { setRevision((value) => value + 1) }
|
|
105
|
+
const controller = createNativeController(ctx.options, (next) => {
|
|
106
|
+
setState(next)
|
|
107
|
+
refreshEconomics()
|
|
108
|
+
})
|
|
109
|
+
const entry: Entry = { state, revision, controller }
|
|
110
|
+
return registerNativeStatusline(ctx, {
|
|
111
|
+
sessionID: () => controller.getContext().session,
|
|
112
|
+
refresh: () => { refreshEconomics(); controller.request() },
|
|
113
|
+
dispose: controller.dispose,
|
|
114
|
+
}, (input) => <NativeLine ctx={ctx} input={input} entry={entry} />)
|
|
115
|
+
},
|
|
116
|
+
} satisfies Plugin.Definition
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { constants } from "node:fs"
|
|
2
|
+
import { lstat, open } from "node:fs/promises"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,256}$/
|
|
7
|
+
const OWNER = "rtrt-opencode-task-agents"
|
|
8
|
+
const MANAGER = "rtrt-manager"
|
|
9
|
+
const STATE_LIMIT = 65_536
|
|
10
|
+
const configuredPath = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0")
|
|
11
|
+
|
|
12
|
+
function boundedState(value, depth = 8, count = { value: 0 }) {
|
|
13
|
+
count.value++
|
|
14
|
+
if (count.value > 512 || depth < 0) return false
|
|
15
|
+
if (!value || typeof value !== "object") return true
|
|
16
|
+
const items = Object.values(value)
|
|
17
|
+
if (items.length > 128) return false
|
|
18
|
+
return items.every((item) => boundedState(item, depth - 1, count))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function secureStateFile(stat, uid) {
|
|
22
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.uid === uid &&
|
|
23
|
+
stat.nlink === 1n && (stat.mode & 0o777n) === 0o600n && stat.size <= BigInt(STATE_LIMIT)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sameFile(left, right) {
|
|
27
|
+
return ["dev", "ino", "uid", "mode", "nlink", "size", "mtimeNs", "ctimeNs"]
|
|
28
|
+
.every((field) => left[field] === right[field])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function secureAncestors(file, uid, statPath = lstat) {
|
|
32
|
+
let directory = path.dirname(file)
|
|
33
|
+
while (true) {
|
|
34
|
+
const stat = await statPath(directory, { bigint: true })
|
|
35
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
36
|
+
(stat.uid !== uid && stat.uid !== 0n) || (stat.mode & 0o022n) !== 0n) return false
|
|
37
|
+
const parent = path.dirname(directory)
|
|
38
|
+
if (parent === directory) return true
|
|
39
|
+
directory = parent
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function managedAgentStatePath(env = process.env) {
|
|
44
|
+
const home = configuredPath(env.HOME) ? env.HOME : homedir()
|
|
45
|
+
const root = configuredPath(env.OPENCODE_CONFIG_DIR)
|
|
46
|
+
? path.resolve(env.OPENCODE_CONFIG_DIR)
|
|
47
|
+
: configuredPath(env.XDG_CONFIG_HOME)
|
|
48
|
+
? path.resolve(env.XDG_CONFIG_HOME, "opencode")
|
|
49
|
+
: path.resolve(home, ".config", "opencode")
|
|
50
|
+
return path.join(root, "agents", ".rtrt-managed-state.json")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loadAgents(file, io = { lstat, open }) {
|
|
54
|
+
let handle
|
|
55
|
+
let result
|
|
56
|
+
try {
|
|
57
|
+
const currentUser = process.getuid?.()
|
|
58
|
+
if (currentUser === undefined || typeof file !== "string" || !path.isAbsolute(file) || file.includes("\0")) return undefined
|
|
59
|
+
const uid = BigInt(currentUser)
|
|
60
|
+
if (!(await secureAncestors(file, uid, io.lstat))) return undefined
|
|
61
|
+
const stat = await io.lstat(file, { bigint: true })
|
|
62
|
+
if (!secureStateFile(stat, uid)) return undefined
|
|
63
|
+
handle = await io.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
64
|
+
const opened = await handle.stat({ bigint: true })
|
|
65
|
+
if (!secureStateFile(opened, uid) || !sameFile(stat, opened)) return undefined
|
|
66
|
+
const raw = await handle.readFile("utf8")
|
|
67
|
+
if (Buffer.byteLength(raw) > STATE_LIMIT) return undefined
|
|
68
|
+
const after = await handle.stat({ bigint: true })
|
|
69
|
+
const finalPath = await io.lstat(file, { bigint: true })
|
|
70
|
+
if (!sameFile(opened, after) || !sameFile(opened, finalPath) ||
|
|
71
|
+
!(await secureAncestors(file, uid, io.lstat))) return undefined
|
|
72
|
+
const state = JSON.parse(raw)
|
|
73
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !boundedState(state)) return undefined
|
|
74
|
+
if (state.owner !== OWNER || state.version !== 1 || !Array.isArray(state.agents)) return undefined
|
|
75
|
+
if (state.agents.length < 1 || state.agents.length > 128 || !state.agents.every((id) =>
|
|
76
|
+
typeof id === "string" && SESSION_ID.test(id))) return undefined
|
|
77
|
+
const agents = new Set(state.agents)
|
|
78
|
+
result = agents.size === state.agents.length && agents.has(MANAGER) ? agents : undefined
|
|
79
|
+
} catch {
|
|
80
|
+
// State is an authorization boundary; unreadable or malformed state fails closed.
|
|
81
|
+
return undefined
|
|
82
|
+
} finally {
|
|
83
|
+
if (handle) {
|
|
84
|
+
try { await handle.close() } catch { result = undefined }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function createPermissionEvaluator(ctx, statePath = managedAgentStatePath(), active = () => true) {
|
|
91
|
+
const managed = await loadAgents(statePath)
|
|
92
|
+
const project = ctx.location.project
|
|
93
|
+
const directory = ctx.location.directory
|
|
94
|
+
return async (request) => {
|
|
95
|
+
if (
|
|
96
|
+
!active() || !managed || request.effect !== "ask" || request.action !== "shell" ||
|
|
97
|
+
request.resources?.length !== 1 || request.resources[0] !== "pwd" ||
|
|
98
|
+
!SESSION_ID.test(request.sessionID ?? "")
|
|
99
|
+
) return
|
|
100
|
+
try {
|
|
101
|
+
const session = await ctx.session.get({ sessionID: request.sessionID })
|
|
102
|
+
if (
|
|
103
|
+
session?.id !== request.sessionID || !managed.has(session.agent) ||
|
|
104
|
+
session.agent === MANAGER || request.agent && request.agent !== session.agent ||
|
|
105
|
+
session.projectID !== project.id || session.location?.directory !== directory ||
|
|
106
|
+
!SESSION_ID.test(session.parentID ?? "")
|
|
107
|
+
) return
|
|
108
|
+
const parent = await ctx.session.get({ sessionID: session.parentID })
|
|
109
|
+
if (
|
|
110
|
+
parent?.id !== session.parentID || parent.agent !== MANAGER ||
|
|
111
|
+
parent.projectID !== project.id || parent.location?.directory !== directory
|
|
112
|
+
) return
|
|
113
|
+
if (active() && request.effect === "ask") request.effect = "allow"
|
|
114
|
+
} catch {
|
|
115
|
+
// Unknown session or unavailable host API must preserve the host's decision.
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|