arkaos 3.67.0 → 3.68.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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.67.0
1
+ 3.68.0
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ // PR99b v3.68.0 — xterm.js terminal mount.
3
+ //
4
+ // Glues @xterm/xterm to a single PTY session from useTerminalSession.
5
+ // One terminal per component instance; the multi-tab page in PR99c
6
+ // will spawn N of these.
7
+
8
+ import { Terminal as XTerm } from '@xterm/xterm'
9
+ import { FitAddon } from '@xterm/addon-fit'
10
+ import { WebLinksAddon } from '@xterm/addon-web-links'
11
+ import { SearchAddon } from '@xterm/addon-search'
12
+ import '@xterm/xterm/css/xterm.css'
13
+
14
+ const container = ref<HTMLDivElement | null>(null)
15
+ const session = useTerminalSession()
16
+ const term = shallowRef<XTerm | null>(null)
17
+ const fit = shallowRef<FitAddon | null>(null)
18
+ const search = shallowRef<SearchAddon | null>(null)
19
+
20
+ const decoder = new TextDecoder('utf-8', { fatal: false })
21
+
22
+ const themeArkaOSDark = {
23
+ background: '#0a0a0f',
24
+ foreground: '#e6e6f0',
25
+ cursor: '#7dd3fc',
26
+ cursorAccent: '#0a0a0f',
27
+ selectionBackground: '#1e3a5f',
28
+ black: '#0a0a0f',
29
+ red: '#f87171',
30
+ green: '#86efac',
31
+ yellow: '#fde68a',
32
+ blue: '#7dd3fc',
33
+ magenta: '#f0abfc',
34
+ cyan: '#67e8f9',
35
+ white: '#e6e6f0',
36
+ brightBlack: '#3f3f46',
37
+ brightRed: '#fca5a5',
38
+ brightGreen: '#bbf7d0',
39
+ brightYellow: '#fef3c7',
40
+ brightBlue: '#bae6fd',
41
+ brightMagenta: '#f5d0fe',
42
+ brightCyan: '#a5f3fc',
43
+ brightWhite: '#fafafa',
44
+ }
45
+
46
+ let unsubscribeOutput: (() => void) | null = null
47
+ let resizeObserver: ResizeObserver | null = null
48
+
49
+ onMounted(async () => {
50
+ if (!container.value) return
51
+
52
+ const t = new XTerm({
53
+ cursorBlink: true,
54
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
55
+ fontSize: 13,
56
+ lineHeight: 1.2,
57
+ scrollback: 5000,
58
+ theme: themeArkaOSDark,
59
+ allowProposedApi: true,
60
+ })
61
+ const fitAddon = new FitAddon()
62
+ const searchAddon = new SearchAddon()
63
+ t.loadAddon(fitAddon)
64
+ t.loadAddon(new WebLinksAddon())
65
+ t.loadAddon(searchAddon)
66
+ t.open(container.value)
67
+ fitAddon.fit()
68
+
69
+ term.value = t
70
+ fit.value = fitAddon
71
+ search.value = searchAddon
72
+
73
+ await session.open()
74
+
75
+ unsubscribeOutput = session.onOutput((chunk) => {
76
+ const text = decoder.decode(chunk, { stream: true })
77
+ t.write(text)
78
+ })
79
+
80
+ t.onData((data) => {
81
+ session.sendInput(data)
82
+ })
83
+
84
+ // Initial size sync once the WS is open.
85
+ watch(session.status, (s) => {
86
+ if (s === 'open') {
87
+ const { cols, rows } = t
88
+ session.sendResize(cols, rows)
89
+ }
90
+ }, { immediate: true })
91
+
92
+ resizeObserver = new ResizeObserver(() => {
93
+ try {
94
+ fitAddon.fit()
95
+ session.sendResize(t.cols, t.rows)
96
+ } catch (_e) {
97
+ // dom may have unmounted
98
+ }
99
+ })
100
+ resizeObserver.observe(container.value)
101
+ })
102
+
103
+ onBeforeUnmount(async () => {
104
+ unsubscribeOutput?.()
105
+ resizeObserver?.disconnect()
106
+ await session.close()
107
+ term.value?.dispose()
108
+ })
109
+
110
+ defineExpose({
111
+ status: session.status,
112
+ error: session.error,
113
+ meta: session.meta,
114
+ })
115
+ </script>
116
+
117
+ <template>
118
+ <div class="relative h-full w-full bg-[#0a0a0f] rounded-lg overflow-hidden border border-default">
119
+ <div
120
+ v-if="session.status.value === 'connecting'"
121
+ class="absolute inset-0 z-10 grid place-items-center text-muted text-sm bg-[#0a0a0f]/80 backdrop-blur"
122
+ >
123
+ <div class="flex items-center gap-2">
124
+ <UIcon name="i-lucide-loader" class="animate-spin size-4" />
125
+ Spawning PTY…
126
+ </div>
127
+ </div>
128
+ <div
129
+ v-else-if="session.status.value === 'error' || session.status.value === 'closed'"
130
+ class="absolute top-2 right-2 z-10 text-xs rounded-md bg-elevated/90 px-2 py-1 border border-default"
131
+ >
132
+ <span v-if="session.status.value === 'error'" class="text-red-400">
133
+ {{ session.error.value || 'error' }}
134
+ </span>
135
+ <span v-else class="text-muted">closed</span>
136
+ </div>
137
+ <div ref="container" class="absolute inset-0 p-2" />
138
+ </div>
139
+ </template>
140
+
141
+ <style scoped>
142
+ :deep(.xterm) {
143
+ height: 100%;
144
+ width: 100%;
145
+ padding: 4px;
146
+ }
147
+ :deep(.xterm-viewport) {
148
+ background-color: transparent !important;
149
+ }
150
+ </style>
@@ -0,0 +1,138 @@
1
+ // PR99b v3.68.0 — single PTY session lifecycle.
2
+ //
3
+ // Encapsulates the REST + WebSocket handshake against /api/terminal/*.
4
+ // The composable owns no DOM and no xterm instance — it just produces
5
+ // the bytes-in/bytes-out duplex. The Terminal.vue component glues this
6
+ // to an xterm.js canvas.
7
+
8
+ export interface TerminalSessionMeta {
9
+ session_id: string
10
+ shell: string
11
+ cwd: string
12
+ token: string
13
+ ws_path: string
14
+ max_sessions: number
15
+ active_count: number
16
+ }
17
+
18
+ export interface TerminalSessionHandle {
19
+ meta: Ref<TerminalSessionMeta | null>
20
+ status: Ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>
21
+ error: Ref<string | null>
22
+ open: () => Promise<void>
23
+ sendInput: (data: string) => void
24
+ sendResize: (cols: number, rows: number) => void
25
+ close: () => Promise<void>
26
+ onOutput: (cb: (chunk: Uint8Array) => void) => () => void
27
+ }
28
+
29
+ export function useTerminalSession(): TerminalSessionHandle {
30
+ const { apiBase } = useApi()
31
+ const meta = ref<TerminalSessionMeta | null>(null)
32
+ const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
33
+ const error = ref<string | null>(null)
34
+
35
+ let ws: WebSocket | null = null
36
+ const listeners: Array<(chunk: Uint8Array) => void> = []
37
+
38
+ function wsUrl(path: string, token: string): string {
39
+ const base = apiBase.replace(/^http/, 'ws')
40
+ return `${base}${path}?token=${encodeURIComponent(token)}`
41
+ }
42
+
43
+ async function createSession(): Promise<TerminalSessionMeta> {
44
+ const r = await fetch(`${apiBase}/api/terminal/sessions`, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ cols: 120, rows: 32 }),
48
+ })
49
+ if (!r.ok) {
50
+ const body = await r.text()
51
+ throw new Error(`create session failed: ${r.status} ${body}`)
52
+ }
53
+ return await r.json()
54
+ }
55
+
56
+ async function open() {
57
+ if (status.value === 'open' || status.value === 'connecting') return
58
+ status.value = 'connecting'
59
+ error.value = null
60
+ try {
61
+ const m = await createSession()
62
+ meta.value = m
63
+ ws = new WebSocket(wsUrl(m.ws_path, m.token))
64
+ ws.binaryType = 'arraybuffer'
65
+ ws.onopen = () => {
66
+ status.value = 'open'
67
+ }
68
+ ws.onmessage = (ev) => {
69
+ if (ev.data instanceof ArrayBuffer) {
70
+ const chunk = new Uint8Array(ev.data)
71
+ for (const cb of listeners) cb(chunk)
72
+ } else if (typeof ev.data === 'string') {
73
+ const enc = new TextEncoder().encode(ev.data)
74
+ for (const cb of listeners) cb(enc)
75
+ }
76
+ }
77
+ ws.onerror = () => {
78
+ status.value = 'error'
79
+ error.value = 'websocket error'
80
+ }
81
+ ws.onclose = (ev) => {
82
+ status.value = 'closed'
83
+ if (ev.code !== 1000 && ev.code !== 1005) {
84
+ error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
85
+ }
86
+ }
87
+ } catch (e) {
88
+ status.value = 'error'
89
+ error.value = e instanceof Error ? e.message : String(e)
90
+ }
91
+ }
92
+
93
+ function sendInput(data: string) {
94
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
95
+ ws.send(JSON.stringify({ type: 'input', data }))
96
+ }
97
+
98
+ function sendResize(cols: number, rows: number) {
99
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
100
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }))
101
+ }
102
+
103
+ async function close() {
104
+ try {
105
+ ws?.close(1000, 'client close')
106
+ } catch (_e) {
107
+ // ignore
108
+ }
109
+ const id = meta.value?.session_id
110
+ if (id) {
111
+ try {
112
+ await fetch(`${apiBase}/api/terminal/sessions/${id}`, { method: 'DELETE' })
113
+ } catch (_e) {
114
+ // ignore — best-effort cleanup; backend reaper will catch it
115
+ }
116
+ }
117
+ status.value = 'closed'
118
+ }
119
+
120
+ function onOutput(cb: (chunk: Uint8Array) => void) {
121
+ listeners.push(cb)
122
+ return () => {
123
+ const idx = listeners.indexOf(cb)
124
+ if (idx >= 0) listeners.splice(idx, 1)
125
+ }
126
+ }
127
+
128
+ return {
129
+ meta,
130
+ status,
131
+ error,
132
+ open,
133
+ sendInput,
134
+ sendResize,
135
+ close,
136
+ onOutput,
137
+ }
138
+ }
@@ -1,278 +1,51 @@
1
1
  <script setup lang="ts">
2
- // PR95a v3.51.0 — Dashboard terminal (allowlist mode).
2
+ // PR99b v3.68.0 — Real-shell terminal (single session).
3
3
  //
4
- // Operator picks one of the allowlisted commands; backend runs it via
5
- // subprocess.run (no shell). Output streams into the history block.
6
- //
7
- // Note: vue-termui is for building Vue TUI apps that RUN in a terminal,
8
- // not for embedding an arbitrary shell in a browser. The dashboard
9
- // instead ships a controlled command runner with allowlist + capped
10
- // output. xterm.js-style PTY can be a later upgrade if needed.
11
-
12
- interface CommandArg {
13
- name: string
14
- label: string
15
- choices: string[]
16
- default: string
17
- }
18
-
19
- interface CommandEntry {
20
- id: string
21
- label: string
22
- description: string
23
- args?: CommandArg[]
24
- }
25
-
26
- interface ExecResult {
27
- stdout: string
28
- stderr: string
29
- exit_code: number
30
- duration_ms: number
31
- command: string
32
- }
33
-
34
- interface HistoryEntry {
35
- id: string
36
- label: string
37
- result: ExecResult
38
- ts: string
39
- }
40
-
41
- const { fetchApi, apiBase } = useApi()
42
- const toast = useToast()
43
-
44
- const { data: cmdData, status } = await fetchApi<{ commands: CommandEntry[] }>(
45
- '/api/terminal/commands',
46
- )
47
- const commands = computed<CommandEntry[]>(() => cmdData.value?.commands ?? [])
48
-
49
- const running = ref<string | null>(null)
50
- const history = ref<HistoryEntry[]>([])
4
+ // Replaces the v3.51.0 allowlist UI. Each visit spawns a fresh PTY
5
+ // session on the backend, wired to xterm.js. Multi-session tabs and
6
+ // history land in PR99c. The old allowlist endpoints stay in parallel
7
+ // until PR99d, but the UI is gone as of this release.
51
8
 
52
- // PR96b v3.56.0 — per-command arg state. Keyed by command id, holds
53
- // the operator's current selection for each arg.
54
- const argState = reactive<Record<string, Record<string, string>>>({})
9
+ definePageMeta({ layout: 'default' })
55
10
 
56
- function ensureArgState(cmd: CommandEntry) {
57
- if (!cmd.args || cmd.args.length === 0) return
58
- if (!argState[cmd.id]) argState[cmd.id] = {}
59
- for (const arg of cmd.args) {
60
- if (argState[cmd.id][arg.name] === undefined) {
61
- argState[cmd.id][arg.name] = arg.default
62
- }
63
- }
64
- }
65
-
66
- async function run(cmd: CommandEntry) {
67
- running.value = cmd.id
68
- ensureArgState(cmd)
69
- try {
70
- const res = await $fetch<ExecResult & { error?: string }>(
71
- `${apiBase}/api/terminal/exec`,
72
- {
73
- method: 'POST',
74
- body: {
75
- command_id: cmd.id,
76
- args: cmd.args ? argState[cmd.id] : undefined,
77
- },
78
- },
79
- )
80
- if (res.error) throw new Error(res.error)
81
- history.value = [
82
- {
83
- id: cmd.id,
84
- label: cmd.label,
85
- result: res,
86
- ts: new Date().toISOString(),
87
- },
88
- ...history.value,
89
- ].slice(0, 20)
90
- if (res.exit_code === 0) {
91
- toast.add({
92
- title: `${cmd.label} · ok`,
93
- description: `${res.duration_ms}ms`,
94
- color: 'success',
95
- icon: 'i-lucide-check',
96
- })
97
- } else {
98
- toast.add({
99
- title: `${cmd.label} · exit ${res.exit_code}`,
100
- description: `${res.duration_ms}ms · ${res.stderr.slice(0, 80) || 'no stderr'}`,
101
- color: 'warning',
102
- })
103
- }
104
- } catch (err) {
105
- toast.add({
106
- title: 'Run failed',
107
- description: err instanceof Error ? err.message : 'unknown error',
108
- color: 'error',
109
- })
110
- } finally {
111
- running.value = null
112
- }
113
- }
114
-
115
- function copyOutput(entry: HistoryEntry) {
116
- if (typeof navigator === 'undefined' || !navigator.clipboard) return
117
- const body = entry.result.stdout || entry.result.stderr
118
- void navigator.clipboard.writeText(body)
119
- toast.add({ title: 'Copied to clipboard', color: 'success', icon: 'i-lucide-clipboard-check' })
120
- }
121
-
122
- function clearHistory() {
123
- history.value = []
124
- }
125
-
126
- function relative(iso: string): string {
127
- const ts = Date.parse(iso)
128
- if (Number.isNaN(ts)) return iso
129
- const diff = Date.now() - ts
130
- const s = Math.floor(diff / 1000)
131
- if (s < 60) return `${s}s ago`
132
- const m = Math.floor(s / 60)
133
- if (m < 60) return `${m}m ago`
134
- return `${Math.floor(m / 60)}h ago`
135
- }
11
+ const terminalRef = ref<InstanceType<typeof import('~/components/Terminal.vue').default> | null>(null)
12
+ const expanded = ref(false)
136
13
  </script>
137
14
 
138
15
  <template>
139
- <UDashboardPanel id="terminal">
140
- <template #header>
141
- <UDashboardNavbar title="Terminal">
142
- <template #leading>
143
- <UDashboardSidebarCollapse />
144
- </template>
145
- <template #trailing>
146
- <UBadge label="Allowlist mode" variant="subtle" color="primary" size="sm" />
147
- </template>
148
- </UDashboardNavbar>
149
- </template>
150
-
151
- <template #body>
152
- <DashboardState
153
- :status="status"
154
- :empty="commands.length === 0"
155
- empty-title="No allowlisted commands"
156
- empty-description="The backend exposes no terminal commands. Add to TERMINAL_ALLOWLIST."
157
- empty-icon="i-lucide-terminal"
158
- >
159
- <div class="space-y-5 max-w-4xl">
160
- <UCard>
161
- <template #header>
162
- <div>
163
- <h3 class="text-lg font-bold">Commands</h3>
164
- <p class="text-xs text-muted mt-0.5">
165
- Server-enforced allowlist. Each command runs via
166
- <code class="font-mono">subprocess.run</code> with explicit argv
167
- no shell, no globbing, no pipes. Cap: 15s timeout, 20K chars per stream.
168
- </p>
169
- </div>
170
- </template>
171
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
172
- <div
173
- v-for="cmd in commands"
174
- :key="cmd.id"
175
- class="rounded-lg border border-default p-3 space-y-2"
176
- >
177
- <div>
178
- <p class="text-sm font-semibold">{{ cmd.label }}</p>
179
- <p class="text-xs text-muted">{{ cmd.description }}</p>
180
- </div>
181
- <!-- PR96b v3.56.0 — arg pickers for parameterised commands -->
182
- <div v-if="cmd.args && cmd.args.length > 0" class="grid grid-cols-2 gap-2">
183
- <UFormField
184
- v-for="arg in cmd.args"
185
- :key="arg.name"
186
- :label="arg.label"
187
- size="xs"
188
- >
189
- <USelect
190
- :model-value="(argState[cmd.id] || {})[arg.name] || arg.default"
191
- :items="arg.choices.map((c) => ({ label: c, value: c }))"
192
- size="xs"
193
- class="w-full"
194
- @update:model-value="(v) => {
195
- ensureArgState(cmd)
196
- argState[cmd.id][arg.name] = String(v)
197
- }"
198
- />
199
- </UFormField>
200
- </div>
201
- <UButton
202
- label="Run"
203
- icon="i-lucide-play"
204
- color="primary"
205
- size="sm"
206
- block
207
- :loading="running === cmd.id"
208
- :disabled="running !== null && running !== cmd.id"
209
- @click="run(cmd)"
210
- />
211
- </div>
212
- </div>
213
- </UCard>
214
-
215
- <UCard v-if="history.length > 0">
216
- <template #header>
217
- <div class="flex items-center justify-between gap-3">
218
- <div>
219
- <h3 class="text-lg font-bold">Recent runs</h3>
220
- <p class="text-xs text-muted mt-0.5">
221
- Last {{ history.length }} commands · most recent first
222
- </p>
223
- </div>
224
- <UButton label="Clear" variant="ghost" size="xs" @click="clearHistory" />
225
- </div>
226
- </template>
227
- <ul class="space-y-4">
228
- <li
229
- v-for="entry in history"
230
- :key="`${entry.ts}-${entry.id}`"
231
- class="rounded-lg border border-default overflow-hidden"
232
- >
233
- <div class="px-3 py-2 bg-elevated/30 flex items-center justify-between gap-3 text-xs">
234
- <div class="min-w-0 flex items-center gap-2">
235
- <UBadge
236
- :label="entry.result.exit_code === 0 ? 'ok' : `exit ${entry.result.exit_code}`"
237
- :color="entry.result.exit_code === 0 ? 'success' : 'warning'"
238
- variant="subtle"
239
- size="xs"
240
- />
241
- <span class="font-mono truncate">{{ entry.result.command }}</span>
242
- </div>
243
- <div class="flex items-center gap-2 shrink-0">
244
- <span class="text-muted font-mono">{{ entry.result.duration_ms }}ms</span>
245
- <span class="text-muted">{{ relative(entry.ts) }}</span>
246
- <UButton
247
- icon="i-lucide-clipboard-copy"
248
- variant="ghost"
249
- size="xs"
250
- aria-label="Copy output"
251
- @click="copyOutput(entry)"
252
- />
253
- </div>
254
- </div>
255
- <pre
256
- v-if="entry.result.stdout"
257
- class="p-3 text-xs font-mono whitespace-pre overflow-x-auto"
258
- >{{ entry.result.stdout }}</pre>
259
- <pre
260
- v-if="entry.result.stderr"
261
- class="p-3 text-xs font-mono whitespace-pre overflow-x-auto text-rose-500 border-t border-default"
262
- >{{ entry.result.stderr }}</pre>
263
- </li>
264
- </ul>
265
- </UCard>
266
-
267
- <p class="text-xs text-muted">
268
- Want a different command? Add it to
269
- <code class="font-mono">TERMINAL_ALLOWLIST</code> in
270
- <code class="font-mono">scripts/dashboard-api.py</code> and
271
- restart the backend. Arbitrary shell execution from the
272
- dashboard is intentionally not supported.
273
- </p>
274
- </div>
275
- </DashboardState>
276
- </template>
277
- </UDashboardPanel>
16
+ <div class="flex flex-col gap-3 h-full">
17
+ <header class="flex items-center justify-between">
18
+ <div>
19
+ <h1 class="text-2xl font-semibold">Terminal</h1>
20
+ <p class="text-sm text-muted">
21
+ Real shell, PTY-backed. Runs the same commands as your local zsh —
22
+ claude, codex, git, anything. Session ends when you leave this page.
23
+ </p>
24
+ </div>
25
+ <div class="flex items-center gap-2">
26
+ <UBadge color="warning" variant="soft" size="sm">
27
+ <UIcon name="i-lucide-shield" class="size-3 mr-1" />
28
+ localhost only
29
+ </UBadge>
30
+ <UButton
31
+ size="xs"
32
+ variant="ghost"
33
+ :icon="expanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
34
+ @click="expanded = !expanded"
35
+ >
36
+ {{ expanded ? 'Restore' : 'Expand' }}
37
+ </UButton>
38
+ </div>
39
+ </header>
40
+
41
+ <Terminal
42
+ ref="terminalRef"
43
+ :class="expanded ? 'fixed inset-4 z-40' : 'flex-1 min-h-[520px]'"
44
+ />
45
+
46
+ <footer class="text-xs text-muted">
47
+ Multi-session tabs, command history search and theme picker arrive in
48
+ PR99c / PR99d. PR99a (backend PTY) is live.
49
+ </footer>
50
+ </div>
278
51
  </template>
@@ -20,6 +20,10 @@
20
20
  "@unovis/vue": "^1.6.4",
21
21
  "@vueuse/core": "^14.2.1",
22
22
  "@vueuse/nuxt": "^14.2.1",
23
+ "@xterm/addon-fit": "^0.11.0",
24
+ "@xterm/addon-search": "^0.16.0",
25
+ "@xterm/addon-web-links": "^0.12.0",
26
+ "@xterm/xterm": "^6.0.0",
23
27
  "date-fns": "^4.1.0",
24
28
  "marked": "^15.0.0",
25
29
  "nuxt": "^4.4.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.67.0",
3
+ "version": "3.68.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.67.0"
3
+ version = "3.68.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}