@agentprojectcontext/apx 1.20.0 → 1.22.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/package.json +4 -37
- package/skills/apx/SKILL.md +23 -0
- package/src/cli/commands/sessions.js +517 -0
- package/src/cli/index.js +51 -0
- package/src/core/runtime-skills/claude-code.md +17 -0
- package/src/core/runtime-skills/codex-cli.md +17 -0
- package/src/tui/_shims/cli-logo.ts +10 -8
- package/src/tui/_shims/opencode-any.ts +2 -2
- package/src/tui/_shims/prompt-display.ts +45 -0
- package/src/tui/_shims/util-locale.ts +101 -7
- package/src/tui/context/sdk-apx.tsx +43 -16
- package/src/tui/context/sync-apx.tsx +47 -1
- package/src/tui/context/sync.tsx +39 -4
- package/src/tui/routes/session/index.tsx +123 -70
- package/src/tui/routes/session/sidebar-apx.tsx +90 -0
- package/src/tui/tsconfig.json +1 -0
package/src/cli/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
cmdSessionResume,
|
|
33
33
|
cmdSessionCompact,
|
|
34
34
|
} from "./commands/session.js";
|
|
35
|
+
import { cmdSessionsList } from "./commands/sessions.js";
|
|
35
36
|
import {
|
|
36
37
|
cmdMcpList,
|
|
37
38
|
cmdMcpAdd,
|
|
@@ -482,6 +483,45 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
482
483
|
],
|
|
483
484
|
examples: ["apx session compact reviewer --conversation abc123"],
|
|
484
485
|
}),
|
|
486
|
+
sessions: topic({
|
|
487
|
+
title: "apx sessions",
|
|
488
|
+
summary: "List AI engine sessions (Claude Code, Codex, APX) non-interactively.",
|
|
489
|
+
usage: ["apx sessions list [--engine <id>] [--project <name>|--dir <path>] [--limit N]"],
|
|
490
|
+
commands: [
|
|
491
|
+
["list | ls", "List engine projects, or sessions of one project."],
|
|
492
|
+
],
|
|
493
|
+
options: [
|
|
494
|
+
["--engine <id>", "apx (default) | claude | codex | antigravity."],
|
|
495
|
+
["--project <name>", "A registered APX project to resolve the directory from."],
|
|
496
|
+
["--dir <path>", "An explicit project directory (for unregistered projects)."],
|
|
497
|
+
["--limit N", "Show only the N most recent sessions."],
|
|
498
|
+
],
|
|
499
|
+
examples: [
|
|
500
|
+
"apx sessions list --engine claude",
|
|
501
|
+
"apx sessions list --engine claude --project iacrmar",
|
|
502
|
+
"apx sessions list --engine codex --dir /path/to/project",
|
|
503
|
+
],
|
|
504
|
+
}),
|
|
505
|
+
"sessions list": topic({
|
|
506
|
+
title: "apx sessions list",
|
|
507
|
+
summary: "List engine projects, or the sessions of one resolved project.",
|
|
508
|
+
usage: [
|
|
509
|
+
"apx sessions list [--engine <id>]",
|
|
510
|
+
"apx sessions list --engine <id> --project <name>",
|
|
511
|
+
"apx sessions list --engine <id> --dir <path>",
|
|
512
|
+
],
|
|
513
|
+
options: [
|
|
514
|
+
["--engine <id>", "apx (default) | claude | codex | antigravity."],
|
|
515
|
+
["--project <name>", "Resolve directory from a registered APX project."],
|
|
516
|
+
["--dir <path>", "Explicit project directory."],
|
|
517
|
+
["--limit N", "Show only the N most recent sessions."],
|
|
518
|
+
],
|
|
519
|
+
examples: [
|
|
520
|
+
"apx sessions list",
|
|
521
|
+
"apx sessions list --engine claude --project iacrmar",
|
|
522
|
+
"apx sessions list --engine codex --dir /Volumes/work/iacrmar",
|
|
523
|
+
],
|
|
524
|
+
}),
|
|
485
525
|
mcp: topic({
|
|
486
526
|
title: "apx mcp",
|
|
487
527
|
summary: "Manage and call MCP servers merged from APC and supported IDE configs.",
|
|
@@ -1049,6 +1089,7 @@ const HELP_ALIASES = new Map(Object.entries({
|
|
|
1049
1089
|
"agent vault ls": "agent vault list",
|
|
1050
1090
|
"session ls": "session list",
|
|
1051
1091
|
"session show": "session get",
|
|
1092
|
+
"sessions ls": "sessions list",
|
|
1052
1093
|
"mcp ls": "mcp list",
|
|
1053
1094
|
"mcp rm": "mcp remove",
|
|
1054
1095
|
"conv list": "conversations list",
|
|
@@ -1140,6 +1181,7 @@ function buildHelp(version) {
|
|
|
1140
1181
|
hCmd("apx session close-stale", 36, "auto-close sessions older than 1h"),
|
|
1141
1182
|
hCmd("apx session resume <id>", 36, "--summary --full (APC + Claude Code transcript)"),
|
|
1142
1183
|
hCmd("apx session compact <slug>", 36, "--conversation <id> collapse history into summary"),
|
|
1184
|
+
hCmd("apx sessions list", 36, "list AI engine sessions --engine claude|codex|apx --project P | --dir D"),
|
|
1143
1185
|
|
|
1144
1186
|
hSec("MCPs"),
|
|
1145
1187
|
hCmd("apx mcp list", 36, ""),
|
|
@@ -1426,6 +1468,15 @@ async function dispatch(cmd, rest) {
|
|
|
1426
1468
|
break;
|
|
1427
1469
|
}
|
|
1428
1470
|
|
|
1471
|
+
case "sessions": {
|
|
1472
|
+
const sub = rest[0];
|
|
1473
|
+
const isListSub = sub === "list" || sub === "ls";
|
|
1474
|
+
const a = parseArgs(isListSub ? rest.slice(1) : rest);
|
|
1475
|
+
if (!sub || isListSub || sub.startsWith("--")) cmdSessionsList(a);
|
|
1476
|
+
else die(`unknown sessions subcommand: ${sub} — try: list`);
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1429
1480
|
case "mcp": {
|
|
1430
1481
|
const sub = rest[0];
|
|
1431
1482
|
const a = parseArgs(rest.slice(1));
|
|
@@ -53,6 +53,23 @@ For high-trust automation in an already sandboxed environment:
|
|
|
53
53
|
claude -p "task" --permission-mode bypassPermissions --output-format json
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
## List and resume sessions
|
|
57
|
+
|
|
58
|
+
Claude Code has no `--list`; `--resume` is always an interactive picker. To list
|
|
59
|
+
sessions non-interactively, use APX:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
apx sessions list --engine claude --project <name> # registered APX project
|
|
63
|
+
apx sessions list --engine claude --dir <path> # any directory
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This prints each session's id and title. To resume one (run from the project directory):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude --continue # most recent session
|
|
70
|
+
claude -p --resume <session-id> "..." # specific session, always with -p (print mode)
|
|
71
|
+
```
|
|
72
|
+
|
|
56
73
|
## APX runtime
|
|
57
74
|
|
|
58
75
|
Run a project agent through Claude Code:
|
|
@@ -60,6 +60,23 @@ codex exec --json --sandbox workspace-write --skip-git-repo-check "task"
|
|
|
60
60
|
`--skip-git-repo-check` matters for APX default runtime dirs such as `~/.apx/projects/default`,
|
|
61
61
|
which may not be Git repositories.
|
|
62
62
|
|
|
63
|
+
## List and resume sessions
|
|
64
|
+
|
|
65
|
+
List Codex sessions for a project non-interactively with APX:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
apx sessions list --engine codex --project <name> # registered APX project
|
|
69
|
+
apx sessions list --engine codex --dir <path> # any directory
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Resume a session:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
codex resume <session-id> # interactive
|
|
76
|
+
codex exec resume <session-id> "..." # non-interactive
|
|
77
|
+
codex resume --last # most recent session
|
|
78
|
+
```
|
|
79
|
+
|
|
63
80
|
## APX runtime
|
|
64
81
|
|
|
65
82
|
Run a project agent through Codex:
|
|
@@ -2,17 +2,19 @@ export type LogoShape = { left: string[]; right: string[] }
|
|
|
2
2
|
|
|
3
3
|
export const logo: LogoShape = {
|
|
4
4
|
left: [
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
5
|
+
" ",
|
|
6
|
+
"█▀▀█ █▀▀█ █▄▄█",
|
|
7
|
+
"█^^█ █__█ _██_",
|
|
8
|
+
"█__█ █▀▀▀ █▀▀█",
|
|
9
9
|
],
|
|
10
10
|
right: [
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
11
|
+
" ▄ ",
|
|
12
|
+
"█▀▀▀ █▀▀█ █▀▀█ █▀▀█",
|
|
13
|
+
"█___ █__█ █__█ █^^^",
|
|
14
|
+
"▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀",
|
|
15
15
|
],
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const go = logo
|
|
19
|
+
|
|
20
|
+
export const marks = "_^~,"
|
|
@@ -96,8 +96,8 @@ export const cmd: any = {}
|
|
|
96
96
|
export const withTimeout: any = (fn: any) => fn
|
|
97
97
|
export const withNetworkOptions: any = () => {}
|
|
98
98
|
export const resolveNetworkOptionsNoConfig: any = () => ({})
|
|
99
|
-
export const displayCharAt: any = () =>
|
|
100
|
-
export const mentionTriggerIndex: any = () =>
|
|
99
|
+
export const displayCharAt: any = () => undefined
|
|
100
|
+
export const mentionTriggerIndex: any = () => undefined
|
|
101
101
|
export const installPlugin: any = () => {}
|
|
102
102
|
export const patchPluginConfig: any = () => {}
|
|
103
103
|
export const readPluginManifest: any = () => ({})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shim for opencode's `@/cli/cmd/prompt-display`.
|
|
2
|
+
// Ported verbatim from opencode so the prompt autocomplete behaves correctly:
|
|
3
|
+
// the catch-all shim used to stub `mentionTriggerIndex` as `() => -1`, which is
|
|
4
|
+
// `!== undefined` and therefore opened the "@" autocomplete on every keystroke,
|
|
5
|
+
// swallowing Enter / Tab / Ctrl+P.
|
|
6
|
+
|
|
7
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
8
|
+
|
|
9
|
+
function displayOffsetIndex(value: string, offset: number) {
|
|
10
|
+
if (offset <= 0) return 0
|
|
11
|
+
|
|
12
|
+
let width = 0
|
|
13
|
+
for (const part of graphemes.segment(value)) {
|
|
14
|
+
const next = width + Bun.stringWidth(part.segment)
|
|
15
|
+
if (next > offset) return part.index
|
|
16
|
+
width = next
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value.length
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
|
|
23
|
+
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function displayCharAt(value: string, offset: number) {
|
|
27
|
+
let width = 0
|
|
28
|
+
for (const part of graphemes.segment(value)) {
|
|
29
|
+
const next = width + Bun.stringWidth(part.segment)
|
|
30
|
+
if (offset === width || offset < next) return part.segment
|
|
31
|
+
width = next
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
|
|
36
|
+
const text = displaySlice(value, 0, offset)
|
|
37
|
+
const index = text.lastIndexOf("@")
|
|
38
|
+
if (index === -1) return
|
|
39
|
+
|
|
40
|
+
const before = index === 0 ? undefined : text[index - 1]
|
|
41
|
+
const query = text.slice(index)
|
|
42
|
+
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
|
43
|
+
return Bun.stringWidth(text.slice(0, index))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,10 +1,104 @@
|
|
|
1
|
+
// Shim for opencode's `@/util/locale`.
|
|
2
|
+
// Ported verbatim from opencode — the previous stub was missing `truncate`,
|
|
3
|
+
// `truncateLeft`, `time`, `datetime`, `duration`, etc., which crashed the
|
|
4
|
+
// command palette dialog (`Locale.truncate is not a function`).
|
|
5
|
+
|
|
6
|
+
function titlecase(str: string) {
|
|
7
|
+
return str.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function time(input: number): string {
|
|
11
|
+
const date = new Date(input)
|
|
12
|
+
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function datetime(input: number): string {
|
|
16
|
+
const date = new Date(input)
|
|
17
|
+
const localTime = time(input)
|
|
18
|
+
const localDate = date.toLocaleDateString()
|
|
19
|
+
return `${localTime} · ${localDate}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function todayTimeOrDateTime(input: number): string {
|
|
23
|
+
const date = new Date(input)
|
|
24
|
+
const now = new Date()
|
|
25
|
+
const isToday =
|
|
26
|
+
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
|
27
|
+
|
|
28
|
+
if (isToday) {
|
|
29
|
+
return time(input)
|
|
30
|
+
} else {
|
|
31
|
+
return datetime(input)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function number(num: number): string {
|
|
36
|
+
if (num >= 1000000) {
|
|
37
|
+
return (num / 1000000).toFixed(1) + "M"
|
|
38
|
+
} else if (num >= 1000) {
|
|
39
|
+
return (num / 1000).toFixed(1) + "K"
|
|
40
|
+
}
|
|
41
|
+
return num.toString()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function duration(input: number) {
|
|
45
|
+
if (input < 1000) {
|
|
46
|
+
return `${input}ms`
|
|
47
|
+
}
|
|
48
|
+
if (input < 60000) {
|
|
49
|
+
return `${(input / 1000).toFixed(1)}s`
|
|
50
|
+
}
|
|
51
|
+
if (input < 3600000) {
|
|
52
|
+
const minutes = Math.floor(input / 60000)
|
|
53
|
+
const seconds = Math.floor((input % 60000) / 1000)
|
|
54
|
+
return `${minutes}m ${seconds}s`
|
|
55
|
+
}
|
|
56
|
+
if (input < 86400000) {
|
|
57
|
+
const hours = Math.floor(input / 3600000)
|
|
58
|
+
const minutes = Math.floor((input % 3600000) / 60000)
|
|
59
|
+
return `${hours}h ${minutes}m`
|
|
60
|
+
}
|
|
61
|
+
const hours = Math.floor(input / 3600000)
|
|
62
|
+
const days = Math.floor((input % 3600000) / 86400000)
|
|
63
|
+
return `${days}d ${hours}h`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(str: string, len: number): string {
|
|
67
|
+
if (str.length <= len) return str
|
|
68
|
+
return str.slice(0, len - 1) + "…"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function truncateLeft(str: string, len: number): string {
|
|
72
|
+
if (str.length <= len) return str
|
|
73
|
+
return "…" + str.slice(-(len - 1))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function truncateMiddle(str: string, maxLength: number = 35): string {
|
|
77
|
+
if (str.length <= maxLength) return str
|
|
78
|
+
|
|
79
|
+
const ellipsis = "…"
|
|
80
|
+
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
|
|
81
|
+
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
|
|
82
|
+
|
|
83
|
+
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pluralize(count: number, singular: string, plural: string): string {
|
|
87
|
+
const template = count === 1 ? singular : plural
|
|
88
|
+
return template.replace("{}", count.toString())
|
|
89
|
+
}
|
|
90
|
+
|
|
1
91
|
export const Locale = {
|
|
92
|
+
titlecase,
|
|
93
|
+
time,
|
|
94
|
+
datetime,
|
|
95
|
+
todayTimeOrDateTime,
|
|
96
|
+
number,
|
|
97
|
+
duration,
|
|
98
|
+
truncate,
|
|
99
|
+
truncateLeft,
|
|
100
|
+
truncateMiddle,
|
|
101
|
+
pluralize,
|
|
102
|
+
// legacy alias kept for any callers expecting `format`
|
|
2
103
|
format: (n: number) => String(n),
|
|
3
|
-
number: (n: number) => n.toLocaleString(),
|
|
4
|
-
titlecase: (s: string) => s.charAt(0).toUpperCase() + s.slice(1),
|
|
5
|
-
truncateMiddle: (s: string, maxLen: number): string => {
|
|
6
|
-
if (s.length <= maxLen) return s
|
|
7
|
-
const half = Math.floor((maxLen - 3) / 2)
|
|
8
|
-
return s.slice(0, half) + "..." + s.slice(s.length - half)
|
|
9
|
-
},
|
|
10
104
|
}
|
|
@@ -18,6 +18,7 @@ function readToken(): string {
|
|
|
18
18
|
|
|
19
19
|
export type ApxEvent =
|
|
20
20
|
| { type: "session.created"; sessionID: string }
|
|
21
|
+
| { type: "user"; sessionID: string; text: string }
|
|
21
22
|
| { type: "chunk"; sessionID: string; chunk: string }
|
|
22
23
|
| { type: "final"; sessionID: string; text: string; usage?: { input_tokens: number; output_tokens: number } }
|
|
23
24
|
| { type: "error"; sessionID: string; error: string }
|
|
@@ -54,13 +55,29 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
54
55
|
prompt: string,
|
|
55
56
|
previousMessages: Array<{ role: string; content: string }> = [],
|
|
56
57
|
) {
|
|
58
|
+
// Do NOT send `model` — the super-agent owns its model (configured at the
|
|
59
|
+
// system level in ~/.apx/config.json). Overriding it from the TUI would
|
|
60
|
+
// bypass that single source of truth. `props.model` is kept only for
|
|
61
|
+
// display in the sidebar.
|
|
57
62
|
const res = await fetch(`${props.url}/projects/${props.pid}/super-agent/chat/stream`, {
|
|
58
63
|
method: "POST",
|
|
59
64
|
headers: headers(),
|
|
60
|
-
body: JSON.stringify({ prompt,
|
|
65
|
+
body: JSON.stringify({ prompt, previousMessages }),
|
|
61
66
|
signal: abort.signal,
|
|
62
67
|
})
|
|
63
|
-
if (!res.ok || !res.body)
|
|
68
|
+
if (!res.ok || !res.body) {
|
|
69
|
+
// Surface the daemon's actual error message (e.g. {"error":"project not found"})
|
|
70
|
+
// instead of a bare status code.
|
|
71
|
+
let detail = ""
|
|
72
|
+
try {
|
|
73
|
+
const body = await res.text()
|
|
74
|
+
const parsed = JSON.parse(body)
|
|
75
|
+
detail = parsed?.error ?? body
|
|
76
|
+
} catch {
|
|
77
|
+
/* non-JSON / empty body */
|
|
78
|
+
}
|
|
79
|
+
throw new Error(detail ? `${detail} (HTTP ${res.status})` : `stream error: ${res.status}`)
|
|
80
|
+
}
|
|
64
81
|
const reader = res.body.getReader()
|
|
65
82
|
const dec = new TextDecoder()
|
|
66
83
|
let buf = ""
|
|
@@ -90,20 +107,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
// The APX daemon has no generic "create session" route — a chat turn is
|
|
111
|
+
// streamed directly through /super-agent/chat/stream. The TUI still needs a
|
|
112
|
+
// stable session id to group messages, so we mint one locally.
|
|
93
113
|
async function createSession(): Promise<string> {
|
|
94
|
-
|
|
95
|
-
const res = await fetch(`${props.url}/projects/${props.pid}/sessions`, {
|
|
96
|
-
method: "POST",
|
|
97
|
-
headers: {
|
|
98
|
-
"content-type": "application/json",
|
|
99
|
-
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify({}),
|
|
102
|
-
signal: abort.signal,
|
|
103
|
-
})
|
|
104
|
-
if (!res.ok) throw new Error(`createSession: ${res.status}`)
|
|
105
|
-
const data = await res.json()
|
|
106
|
-
return (data as any).id as string
|
|
114
|
+
return `apx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
function runShell(sessionID: string, command: string, cwd: string = process.cwd()): Promise<{ shellID: string; exitCode: number | null }> {
|
|
@@ -170,7 +178,26 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
170
178
|
delete: async (_opts: any) => ({ data: undefined }),
|
|
171
179
|
fork: async (_opts: any) => ({ data: undefined, error: new Error("not supported") }),
|
|
172
180
|
abort: async (_opts: any) => {},
|
|
173
|
-
prompt
|
|
181
|
+
// Called by the opencode home prompt on submit. Extract the text from
|
|
182
|
+
// the message parts, surface it as a user bubble, then stream the reply.
|
|
183
|
+
prompt: async (opts: any) => {
|
|
184
|
+
const sid: string = opts?.sessionID || (await createSession())
|
|
185
|
+
const text = ((opts?.parts ?? []) as any[])
|
|
186
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
187
|
+
.map((p) => p.text)
|
|
188
|
+
.join("\n")
|
|
189
|
+
.trim()
|
|
190
|
+
if (!text) return { data: undefined }
|
|
191
|
+
emitter.emit("event", { type: "user", sessionID: sid, text })
|
|
192
|
+
void streamChat(sid, text).catch((err) => {
|
|
193
|
+
emitter.emit("event", {
|
|
194
|
+
type: "error",
|
|
195
|
+
sessionID: sid,
|
|
196
|
+
error: err instanceof Error ? err.message : String(err),
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
return { data: { id: sid } }
|
|
200
|
+
},
|
|
174
201
|
shell: async (opts: { sessionID?: string; command?: string; cwd?: string }) => {
|
|
175
202
|
if (!opts?.command) return { data: undefined }
|
|
176
203
|
const sid = opts.sessionID || (await createSession())
|
|
@@ -35,16 +35,45 @@ export const { use: useApxSync, provider: ApxSyncProvider } = createSimpleContex
|
|
|
35
35
|
messages: Record<string, ApxMessage[]>
|
|
36
36
|
currentSessionID: string | undefined
|
|
37
37
|
previousMessages: Array<{ role: string; content: string }>
|
|
38
|
+
usage: { input: number; output: number }
|
|
38
39
|
}>({
|
|
39
40
|
status: "loading",
|
|
40
41
|
sessions: [],
|
|
41
42
|
messages: {},
|
|
42
43
|
currentSessionID: undefined,
|
|
43
44
|
previousMessages: [],
|
|
45
|
+
usage: { input: 0, output: 0 },
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
// Listen to APX stream events
|
|
47
49
|
sdk.event.on("event", (ev: ApxEvent) => {
|
|
50
|
+
if (ev.type === "user") {
|
|
51
|
+
const e = ev
|
|
52
|
+
batch(() => {
|
|
53
|
+
setStore(
|
|
54
|
+
"messages",
|
|
55
|
+
produce((draft) => {
|
|
56
|
+
;(draft[e.sessionID] ??= []).push({
|
|
57
|
+
id: `user-${Date.now()}`,
|
|
58
|
+
sessionID: e.sessionID,
|
|
59
|
+
role: "user",
|
|
60
|
+
text: e.text,
|
|
61
|
+
})
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
setStore("previousMessages", (prev) => [...prev, { role: "user", content: e.text }])
|
|
65
|
+
setStore("currentSessionID", e.sessionID)
|
|
66
|
+
setStore(
|
|
67
|
+
"sessions",
|
|
68
|
+
produce((draft) => {
|
|
69
|
+
if (!draft.some((s) => s.id === e.sessionID)) {
|
|
70
|
+
draft.unshift({ id: e.sessionID, title: e.text.slice(0, 60) || "New session" })
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
if (ev.type === "chunk") {
|
|
49
78
|
const e = ev
|
|
50
79
|
setStore(
|
|
@@ -74,13 +103,30 @@ export const { use: useApxSync, provider: ApxSyncProvider } = createSimpleContex
|
|
|
74
103
|
produce((draft) => {
|
|
75
104
|
const msgs = (draft[e.sessionID] ??= [])
|
|
76
105
|
const last = msgs[msgs.length - 1]
|
|
77
|
-
if (last?.role === "assistant") {
|
|
106
|
+
if (last?.role === "assistant" && last.streaming) {
|
|
107
|
+
// A streaming bubble already exists (chunk events arrived) — finalize it.
|
|
78
108
|
last.text = e.text
|
|
79
109
|
last.streaming = false
|
|
110
|
+
} else {
|
|
111
|
+
// The super-agent delivers the whole reply in `final` with no
|
|
112
|
+
// preceding chunks — create the assistant bubble here.
|
|
113
|
+
msgs.push({
|
|
114
|
+
id: `msg-${Date.now()}`,
|
|
115
|
+
sessionID: e.sessionID,
|
|
116
|
+
role: "assistant",
|
|
117
|
+
text: e.text,
|
|
118
|
+
streaming: false,
|
|
119
|
+
})
|
|
80
120
|
}
|
|
81
121
|
}),
|
|
82
122
|
)
|
|
83
123
|
setStore("previousMessages", (prev) => [...prev, { role: "assistant", content: e.text }])
|
|
124
|
+
if (e.usage) {
|
|
125
|
+
setStore("usage", (u) => ({
|
|
126
|
+
input: u.input + (e.usage?.input_tokens ?? 0),
|
|
127
|
+
output: u.output + (e.usage?.output_tokens ?? 0),
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
84
130
|
}
|
|
85
131
|
|
|
86
132
|
if (ev.type === "shell.start") {
|
package/src/tui/context/sync.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createSimpleContext } from "./helper"
|
|
9
9
|
import { useApxSync } from "./sync-apx"
|
|
10
|
+
import { useArgs } from "./args"
|
|
10
11
|
import { onMount } from "solid-js"
|
|
11
12
|
|
|
12
13
|
// Re-export useApxSync as useSync for compatibility
|
|
@@ -14,25 +15,59 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|
|
14
15
|
name: "Sync",
|
|
15
16
|
init: () => {
|
|
16
17
|
const apx = useApxSync()
|
|
18
|
+
const args = useArgs()
|
|
17
19
|
|
|
18
20
|
onMount(() => {
|
|
19
21
|
// APX sync already loads sessions in its own onMount
|
|
20
22
|
})
|
|
21
23
|
|
|
24
|
+
// APX has a single configured super-agent (passed via --agent). The opencode
|
|
25
|
+
// TUI expects `data.agent` to be a non-empty list, otherwise the prompt has
|
|
26
|
+
// no "current agent" and Enter silently refuses to submit.
|
|
27
|
+
const apxAgent = () => ({
|
|
28
|
+
name: args.agent || "apx",
|
|
29
|
+
mode: "primary" as const,
|
|
30
|
+
hidden: false,
|
|
31
|
+
description: "APX super-agent",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// APX uses freeform model strings and resolves the model server-side from
|
|
35
|
+
// the CLI `--model` arg. The opencode TUI, however, refuses to submit unless
|
|
36
|
+
// `local.model.current()` resolves to a model that exists in some provider.
|
|
37
|
+
// Expose a synthetic provider containing the configured model so model
|
|
38
|
+
// resolution / validation passes. The values are cosmetic — the daemon
|
|
39
|
+
// ignores them and uses the CLI model string directly.
|
|
40
|
+
const apxModelKey = () => args.model || "apx-default"
|
|
41
|
+
const apxProvider = () => ({
|
|
42
|
+
id: "apx",
|
|
43
|
+
name: "APX",
|
|
44
|
+
models: {
|
|
45
|
+
[apxModelKey()]: {
|
|
46
|
+
id: apxModelKey(),
|
|
47
|
+
name: apxModelKey(),
|
|
48
|
+
capabilities: { reasoning: false },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
22
53
|
// Return a compatible object that matches the shape expected by existing TUI components
|
|
23
54
|
return {
|
|
24
55
|
data: {
|
|
25
56
|
get status() {
|
|
26
57
|
return apx.status
|
|
27
58
|
},
|
|
28
|
-
// Provider fields
|
|
29
|
-
provider
|
|
59
|
+
// Provider fields — synthetic single provider wrapping the APX model.
|
|
60
|
+
get provider() {
|
|
61
|
+
return [apxProvider()] as any[]
|
|
62
|
+
},
|
|
30
63
|
provider_default: {} as Record<string, string>,
|
|
31
64
|
provider_next: { all: [], default: {}, connected: [] } as any,
|
|
32
65
|
provider_auth: {} as Record<string, any[]>,
|
|
33
66
|
console_state: { switchableOrgCount: 0 } as any,
|
|
34
|
-
// Agent fields —
|
|
35
|
-
agent
|
|
67
|
+
// Agent fields — APX exposes the single configured super-agent.
|
|
68
|
+
get agent() {
|
|
69
|
+
return [apxAgent()] as any[]
|
|
70
|
+
},
|
|
36
71
|
command: [] as any[],
|
|
37
72
|
// Session-related — delegate to APX
|
|
38
73
|
get session() {
|