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.
@@ -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
+ }