@obov/mext 0.4.0 → 0.6.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.
Files changed (164) hide show
  1. package/dist/approval.d.ts +33 -0
  2. package/dist/approval.js +36 -0
  3. package/dist/approval.js.map +1 -0
  4. package/dist/audit.d.ts +123 -0
  5. package/dist/audit.js +221 -0
  6. package/dist/audit.js.map +1 -0
  7. package/dist/build-cli.d.ts +1 -0
  8. package/dist/build-cli.js +2 -1
  9. package/dist/build-cli.js.map +1 -1
  10. package/dist/build.d.ts +2 -0
  11. package/dist/build.js +30 -3
  12. package/dist/build.js.map +1 -1
  13. package/dist/caller.d.ts +53 -0
  14. package/dist/caller.js +51 -0
  15. package/dist/caller.js.map +1 -0
  16. package/dist/cli/build.js +6 -3
  17. package/dist/cli/build.js.map +1 -1
  18. package/dist/cli/dev-audit.d.ts +25 -0
  19. package/dist/cli/dev-audit.js +84 -0
  20. package/dist/cli/dev-audit.js.map +1 -0
  21. package/dist/cli/dev-lock.d.ts +3 -1
  22. package/dist/cli/dev-lock.js +12 -5
  23. package/dist/cli/dev-lock.js.map +1 -1
  24. package/dist/cli/dev-server.d.ts +9 -0
  25. package/dist/cli/dev-server.js +24 -1
  26. package/dist/cli/dev-server.js.map +1 -1
  27. package/dist/cli/dev-tap.d.ts +20 -0
  28. package/dist/cli/dev-tap.js +70 -0
  29. package/dist/cli/dev-tap.js.map +1 -0
  30. package/dist/cli/dev.js +16 -4
  31. package/dist/cli/dev.js.map +1 -1
  32. package/dist/cli/help.js +2 -0
  33. package/dist/cli/help.js.map +1 -1
  34. package/dist/cli/inspect.d.ts +7 -0
  35. package/dist/cli/inspect.js +699 -0
  36. package/dist/cli/inspect.js.map +1 -0
  37. package/dist/cli/main.js +8 -0
  38. package/dist/cli/main.js.map +1 -1
  39. package/dist/cli/output.d.ts +1 -1
  40. package/dist/cli/output.js +3 -2
  41. package/dist/cli/output.js.map +1 -1
  42. package/dist/cli/run.js +28 -4
  43. package/dist/cli/run.js.map +1 -1
  44. package/dist/cli/types.d.ts +9 -0
  45. package/dist/cli/types.js +77 -0
  46. package/dist/cli/types.js.map +1 -0
  47. package/dist/client/index.d.ts +10 -0
  48. package/dist/client/index.js +7 -0
  49. package/dist/client/index.js.map +1 -0
  50. package/dist/client/manifest.js +6 -2
  51. package/dist/client/manifest.js.map +1 -1
  52. package/dist/client/runner.js +37 -3
  53. package/dist/client/runner.js.map +1 -1
  54. package/dist/client/typed.d.ts +54 -0
  55. package/dist/client/typed.js +490 -0
  56. package/dist/client/typed.js.map +1 -0
  57. package/dist/client/types.d.ts +196 -0
  58. package/dist/client/types.js +2 -0
  59. package/dist/client/types.js.map +1 -0
  60. package/dist/command.d.ts +61 -5
  61. package/dist/command.js +42 -1
  62. package/dist/command.js.map +1 -1
  63. package/dist/config.d.ts +42 -10
  64. package/dist/config.js +32 -7
  65. package/dist/config.js.map +1 -1
  66. package/dist/errors.d.ts +21 -3
  67. package/dist/errors.js +13 -0
  68. package/dist/errors.js.map +1 -1
  69. package/dist/expose.d.ts +9 -0
  70. package/dist/expose.js +52 -0
  71. package/dist/expose.js.map +1 -0
  72. package/dist/index.d.ts +15 -6
  73. package/dist/index.js +7 -2
  74. package/dist/index.js.map +1 -1
  75. package/dist/inspect/agent.d.ts +104 -0
  76. package/dist/inspect/agent.js +125 -0
  77. package/dist/inspect/agent.js.map +1 -0
  78. package/dist/inspect/audit.d.ts +46 -0
  79. package/dist/inspect/audit.js +133 -0
  80. package/dist/inspect/audit.js.map +1 -0
  81. package/dist/inspect/compare.d.ts +12 -0
  82. package/dist/inspect/compare.js +31 -0
  83. package/dist/inspect/compare.js.map +1 -0
  84. package/dist/inspect/index.d.ts +17 -0
  85. package/dist/inspect/index.js +12 -0
  86. package/dist/inspect/index.js.map +1 -0
  87. package/dist/inspect/inspector.d.ts +168 -0
  88. package/dist/inspect/inspector.js +392 -0
  89. package/dist/inspect/inspector.js.map +1 -0
  90. package/dist/inspect/profiles.d.ts +95 -0
  91. package/dist/inspect/profiles.js +235 -0
  92. package/dist/inspect/profiles.js.map +1 -0
  93. package/dist/inspect/scenario.d.ts +126 -0
  94. package/dist/inspect/scenario.js +555 -0
  95. package/dist/inspect/scenario.js.map +1 -0
  96. package/dist/inspect/ui-server.d.ts +31 -0
  97. package/dist/inspect/ui-server.js +378 -0
  98. package/dist/inspect/ui-server.js.map +1 -0
  99. package/dist/inspect/ui.html +917 -0
  100. package/dist/instrumentation.d.ts +13 -2
  101. package/dist/instrumentation.js +11 -1
  102. package/dist/instrumentation.js.map +1 -1
  103. package/dist/invoke.d.ts +32 -5
  104. package/dist/invoke.js +150 -16
  105. package/dist/invoke.js.map +1 -1
  106. package/dist/manifest.d.ts +13 -2
  107. package/dist/manifest.js +5 -1
  108. package/dist/manifest.js.map +1 -1
  109. package/dist/mcp/constants.d.ts +5 -0
  110. package/dist/mcp/constants.js +5 -0
  111. package/dist/mcp/constants.js.map +1 -1
  112. package/dist/mcp/dispatch.d.ts +1 -1
  113. package/dist/mcp/dispatch.js +14 -3
  114. package/dist/mcp/dispatch.js.map +1 -1
  115. package/dist/mcp/help.d.ts +2 -0
  116. package/dist/mcp/help.js +6 -1
  117. package/dist/mcp/help.js.map +1 -1
  118. package/dist/mcp/http.d.ts +16 -2
  119. package/dist/mcp/http.js +76 -7
  120. package/dist/mcp/http.js.map +1 -1
  121. package/dist/mcp/index.d.ts +1 -1
  122. package/dist/mcp/index.js +1 -1
  123. package/dist/mcp/index.js.map +1 -1
  124. package/dist/mcp/install.d.ts +1 -1
  125. package/dist/mcp/install.js +3 -2
  126. package/dist/mcp/install.js.map +1 -1
  127. package/dist/mcp/server.d.ts +12 -1
  128. package/dist/mcp/server.js +63 -17
  129. package/dist/mcp/server.js.map +1 -1
  130. package/dist/mcp/stdio.d.ts +4 -0
  131. package/dist/mcp/stdio.js +28 -0
  132. package/dist/mcp/stdio.js.map +1 -1
  133. package/dist/mcp/tasks.d.ts +3 -1
  134. package/dist/mcp/tasks.js +6 -3
  135. package/dist/mcp/tasks.js.map +1 -1
  136. package/dist/principal.d.ts +22 -0
  137. package/dist/principal.js +29 -0
  138. package/dist/principal.js.map +1 -1
  139. package/dist/registry.d.ts +5 -0
  140. package/dist/registry.js +21 -0
  141. package/dist/registry.js.map +1 -1
  142. package/dist/remote/client.d.ts +2 -0
  143. package/dist/remote/client.js +3 -2
  144. package/dist/remote/client.js.map +1 -1
  145. package/dist/remote/oauth.d.ts +1 -1
  146. package/dist/remote/oauth.js +1 -1
  147. package/dist/remote/oauth.js.map +1 -1
  148. package/dist/runtime.d.ts +10 -0
  149. package/dist/runtime.js +11 -0
  150. package/dist/runtime.js.map +1 -0
  151. package/dist/start.js +6 -2
  152. package/dist/start.js.map +1 -1
  153. package/dist/suggest.js +1 -1
  154. package/dist/suggest.js.map +1 -1
  155. package/dist/typegen.d.ts +27 -0
  156. package/dist/typegen.js +62 -0
  157. package/dist/typegen.js.map +1 -0
  158. package/dist/version-constant.d.ts +5 -0
  159. package/dist/version-constant.js +6 -0
  160. package/dist/version-constant.js.map +1 -0
  161. package/dist/version.d.ts +1 -2
  162. package/dist/version.js +1 -2
  163. package/dist/version.js.map +1 -1
  164. package/package.json +14 -3
@@ -0,0 +1,917 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="mext-inspect-token" content="__MEXT_INSPECT_TOKEN__">
7
+ <title>mext inspect</title>
8
+ <style nonce="__MEXT_INSPECT_NONCE__">
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f6f7f9; --panel: #ffffff; --line: #dfe3e8; --text: #1c2128; --dim: #667085; --accent: #2f6feb; --accent-text: #ffffff;
12
+ --ok: #1a7f37; --bad: #c62828; --warn: #9a6700; --chip: #eef1f5; --code: #f1f3f6; --sel: #e3edff;
13
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #e6e9ee; --dim: #8b96a5; --accent: #5b93ff; --accent-text: #0b1220;
17
+ --ok: #4cc26b; --bad: #ff7b72; --warn: #e3b341; --chip: #222830; --code: #11151a; --sel: #1d2a44; }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html, body { height: 100%; margin: 0; }
21
+ body { font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
22
+ button, input, select, textarea { font: inherit; color: inherit; }
23
+ button { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
24
+ button:hover:not(:disabled) { border-color: var(--accent); }
25
+ button:disabled { opacity: .5; cursor: default; }
26
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
27
+ button.link { border: 0; background: none; padding: 0; color: var(--accent); }
28
+ input[type=text], input[type=number], input[type=search], select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; width: 100%; }
29
+ textarea { font-family: var(--mono); font-size: 12px; resize: vertical; min-height: 60px; }
30
+ pre { margin: 0; font: 12px/1.5 var(--mono); background: var(--code); border-radius: 6px; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
31
+ code { font-family: var(--mono); font-size: 12px; }
32
+ header { display: flex; align-items: center; gap: 14px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
33
+ header h1 { font-size: 14px; margin: 0; }
34
+ header .target { color: var(--dim); font-family: var(--mono); font-size: 12px; }
35
+ .grow { flex: 1; }
36
+ header label { color: var(--dim); display: flex; align-items: center; gap: 6px; }
37
+ header select { width: auto; }
38
+ .chips { display: flex; gap: 4px; flex-wrap: wrap; }
39
+ .chip { display: inline-block; background: var(--chip); border-radius: 999px; padding: 1px 8px; font-size: 11px; white-space: nowrap; }
40
+ button.chip { border: 1px solid transparent; }
41
+ button.chip.on { background: var(--sel); border-color: var(--accent); }
42
+ .chip.ro { color: var(--ok); } .chip.w { color: var(--warn); } .chip.d { color: var(--bad); font-weight: 600; } .chip.c { color: var(--accent); }
43
+ .chip.hidden { color: var(--dim); text-decoration: line-through; }
44
+ main { flex: 1; min-height: 0; display: grid; grid-template-columns: 280px minmax(0, 1fr) 380px; }
45
+ main > section { min-height: 0; overflow: auto; }
46
+ #tree { border-right: 1px solid var(--line); background: var(--panel); padding: 10px; }
47
+ #side { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; overflow: hidden; }
48
+ #center { padding: 14px 18px; }
49
+ #tree details { margin-top: 8px; }
50
+ #tree summary { cursor: pointer; font-weight: 600; }
51
+ #tree summary small { color: var(--dim); font-weight: 400; margin-left: 6px; }
52
+ .cmd { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 0; border-radius: 6px; padding: 3px 6px 3px 14px; background: none; }
53
+ .cmd:hover { background: var(--chip); }
54
+ .cmd.sel { background: var(--sel); }
55
+ .cmd.dim .name { color: var(--dim); }
56
+ .cmd .name { flex: 1; font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ .muted { color: var(--dim); }
58
+ .empty { color: var(--dim); padding: 24px 8px; text-align: center; }
59
+ h2 { font-size: 16px; margin: 0 0 2px; font-family: var(--mono); }
60
+ h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin: 18px 0 6px; }
61
+ .meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin-top: 10px; }
62
+ .meta dt { color: var(--dim); } .meta dd { margin: 0; }
63
+ .field { display: grid; grid-template-columns: 180px 1fr; gap: 4px 12px; align-items: start; margin-bottom: 8px; }
64
+ .field label { font-family: var(--mono); font-size: 12px; padding-top: 5px; overflow-wrap: anywhere; }
65
+ .field .req { color: var(--bad); }
66
+ .field small { display: block; color: var(--dim); margin-top: 2px; }
67
+ .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
68
+ .row.actions { margin-top: 12px; }
69
+ .card { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); margin-top: 10px; overflow: hidden; }
70
+ .card > .head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
71
+ .card > .body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
72
+ .card details summary { cursor: pointer; color: var(--dim); }
73
+ .ok { color: var(--ok); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
74
+ .note { border-left: 3px solid var(--warn); padding: 4px 10px; background: var(--chip); border-radius: 0 6px 6px 0; }
75
+ .bar { height: 6px; background: var(--chip); border-radius: 3px; overflow: hidden; }
76
+ .bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .15s; }
77
+ .grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
78
+ .tabs { display: flex; border-bottom: 1px solid var(--line); }
79
+ .tabs button { border: 0; border-radius: 0; border-bottom: 2px solid transparent; padding: 8px 12px; background: none; }
80
+ .tabs button.on { border-bottom-color: var(--accent); font-weight: 600; }
81
+ #panel { flex: 1; overflow: auto; padding: 8px; }
82
+ .item { display: block; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 6px 4px; background: none; }
83
+ .item:hover { background: var(--chip); }
84
+ .item .top { display: flex; gap: 6px; align-items: baseline; }
85
+ .item .top .name { font-family: var(--mono); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
86
+ .item small { color: var(--dim); }
87
+ .steps { margin: 6px 0 0; padding: 0; list-style: none; }
88
+ .steps li { padding: 2px 0; }
89
+ .steps .fail { color: var(--dim); font-family: var(--mono); font-size: 11px; padding-left: 18px; }
90
+ .steps .agent { padding-left: 18px; }
91
+ .steps .agent h5 { margin: 8px 0 2px; font-size: 11px; color: var(--dim); font-weight: 600; }
92
+ .steps .agent .said { white-space: pre-wrap; padding: 2px 0; }
93
+ dialog { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 16px; width: min(520px, 90vw); }
94
+ dialog::backdrop { background: rgba(0, 0, 0, .45); }
95
+ dialog h4 { margin: 0 0 8px; font-size: 14px; }
96
+ dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
97
+ #toast { color: var(--dim); }
98
+ @media (max-width: 1100px) { main { grid-template-columns: 240px minmax(0, 1fr); } #side { display: none; } }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <header>
103
+ <h1>mext inspect</h1>
104
+ <span class="target" id="target"></span>
105
+ <span class="grow"></span>
106
+ <span id="toast"></span>
107
+ <label>Call as <select id="profile"></select></label>
108
+ <label>Compare <span class="chips" id="compare"></span></label>
109
+ </header>
110
+ <main>
111
+ <section id="tree"></section>
112
+ <section id="center"></section>
113
+ <section id="side">
114
+ <div class="tabs" id="tabs"></div>
115
+ <div id="panel"></div>
116
+ </section>
117
+ </main>
118
+ <dialog id="dialog"></dialog>
119
+ <script nonce="__MEXT_INSPECT_NONCE__">
120
+ "use strict"
121
+ // mext inspect 웹 UI (specs/inspector.md §5). 의존성 없음. 대상 서버 호출은 전부 같은 출처의 /api 를 거친다
122
+ // (토큰 · 비밀 헤더는 inspector 프로세스에만 있다). 서버가 준 값은 textContent 로만 넣는다 (innerHTML 없음)
123
+
124
+ const TOKEN = document.querySelector('meta[name="mext-inspect-token"]').content
125
+ const $ = (id) => document.getElementById(id)
126
+
127
+ /** DOM 생성: h("div", { class: "x", onclick }, ...자식). 문자열 자식 = 텍스트 노드 */
128
+ function h(tag, attrs, ...children) {
129
+ const el = document.createElement(tag)
130
+ for (const [key, value] of Object.entries(attrs || {})) {
131
+ if (value === undefined || value === false || value === null) continue
132
+ if (key.startsWith("on")) el.addEventListener(key.slice(2), value)
133
+ else if (key === "class") el.className = value
134
+ else if (key in el && key !== "list") el[key] = value
135
+ else el.setAttribute(key, value === true ? "" : String(value))
136
+ }
137
+ for (const child of children.flat(Infinity)) {
138
+ if (child === undefined || child === null || child === false) continue
139
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)))
140
+ }
141
+ return el
142
+ }
143
+ const pretty = (value) => (value === undefined ? "undefined" : JSON.stringify(value, null, 2))
144
+ const jsonBlock = (value) => h("pre", {}, typeof value === "string" ? value : pretty(value))
145
+
146
+ /* ---------------------------------------------------------------- API */
147
+
148
+ function api(path, init = {}) {
149
+ const headers = { "X-Mext-Inspect": TOKEN, ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}) }
150
+ return fetch(path, { ...init, headers })
151
+ }
152
+ async function getJson(path, init) {
153
+ const response = await api(path, init)
154
+ const body = await response.json()
155
+ if (!response.ok) throw Object.assign(new Error(body.message || (body.error && body.error.message) || response.statusText), { body })
156
+ return body
157
+ }
158
+ const postJson = (path, body) => getJson(path, { method: "POST", body: JSON.stringify(body) })
159
+
160
+ /** NDJSON 스트림을 한 줄씩 */
161
+ async function* lines(response) {
162
+ const reader = response.body.getReader()
163
+ const decoder = new TextDecoder()
164
+ let buffer = ""
165
+ for (;;) {
166
+ const { done, value } = await reader.read()
167
+ if (done) break
168
+ buffer += decoder.decode(value, { stream: true })
169
+ let i
170
+ while ((i = buffer.indexOf("\n")) >= 0) {
171
+ const text = buffer.slice(0, i)
172
+ buffer = buffer.slice(i + 1)
173
+ if (text !== "") yield JSON.parse(text)
174
+ }
175
+ }
176
+ }
177
+ async function* stream(path, body, signal) {
178
+ const response = await api(path, { method: "POST", body: JSON.stringify(body), signal })
179
+ if (!response.ok) {
180
+ const error = await response.json()
181
+ throw Object.assign(new Error(error.message || response.statusText), { body: error })
182
+ }
183
+ yield* lines(response)
184
+ }
185
+
186
+ /* ---------------------------------------------------------------- 상태 */
187
+
188
+ const S = {
189
+ state: null,
190
+ profile: "",
191
+ compare: new Set(),
192
+ lists: {}, // 프로필 → { ok, list | error }
193
+ selected: null, // 명령어 ID
194
+ info: null, // CommandInfo
195
+ visibleTo: {},
196
+ describeError: null,
197
+ mode: "form",
198
+ filter: "",
199
+ tab: "history",
200
+ history: [],
201
+ audit: [],
202
+ scenarios: [],
203
+ scenarioRuns: {}, // 파일 → { running, steps, result }
204
+ cards: [], // 가운데 결과 카드
205
+ running: null, // AbortController
206
+ confirmed: false,
207
+ }
208
+ let form = { fields: [], json: null }
209
+
210
+ const involved = () => [S.profile, ...[...S.compare].filter((p) => p !== S.profile)]
211
+ const toast = (text) => {
212
+ $("toast").textContent = text
213
+ clearTimeout(toast.timer)
214
+ toast.timer = setTimeout(() => ($("toast").textContent = ""), 4000)
215
+ }
216
+
217
+ /* ---------------------------------------------------------------- 머리말 · 프로필 */
218
+
219
+ function renderHeader() {
220
+ const { target, server, profiles } = S.state
221
+ const info = server && !server.error ? ` · ${server.name || "server"}${server.version ? ` ${server.version}` : ""} · tools: ${server.tools}` : ""
222
+ $("target").textContent = `${target.url}${target.source === "dev" ? " (mext dev)" : ""}${info}`
223
+ $("profile").replaceChildren(
224
+ ...profiles.map((p) => h("option", { value: p.name, selected: p.name === S.profile, disabled: !p.available }, p.available ? p.name : `${p.name} (env not set)`)),
225
+ )
226
+ $("compare").replaceChildren(
227
+ ...profiles
228
+ .filter((p) => p.name !== S.profile)
229
+ .map((p) =>
230
+ h(
231
+ "button",
232
+ {
233
+ class: `chip${S.compare.has(p.name) ? " on" : ""}`,
234
+ disabled: !p.available,
235
+ title: p.available ? p.description || "" : `Needs ${[p.token && !p.token.set ? p.token.env : null, ...p.headers.filter((x) => x.set === false).map((x) => x.env)].filter(Boolean).join(", ")}`,
236
+ onclick: () => {
237
+ S.compare.has(p.name) ? S.compare.delete(p.name) : S.compare.add(p.name)
238
+ void reload()
239
+ },
240
+ },
241
+ p.name,
242
+ ),
243
+ ),
244
+ )
245
+ }
246
+ $("profile").addEventListener("change", (event) => {
247
+ S.profile = event.target.value
248
+ S.compare.delete(S.profile)
249
+ void reload()
250
+ })
251
+
252
+ /* ---------------------------------------------------------------- 트리 */
253
+
254
+ function safetyChips(c) {
255
+ const chips = [h("span", { class: `chip ${c.readOnly ? "ro" : "w"}` }, c.readOnly ? "read-only" : "writes")]
256
+ if (c.destructive) chips.push(h("span", { class: "chip d" }, "destructive"))
257
+ if (c.requiresConfirmation) chips.push(h("span", { class: "chip c" }, "confirm"))
258
+ else if (c.needsApproval === "conditional") chips.push(h("span", { class: "chip c" }, "may confirm"))
259
+ return chips
260
+ }
261
+
262
+ function renderTree() {
263
+ const names = involved()
264
+ const all = new Map() // ID → { summary, visible: Set }
265
+ const descriptions = new Map()
266
+ const errors = []
267
+ for (const name of names) {
268
+ const entry = S.lists[name]
269
+ if (!entry) continue
270
+ if (!entry.ok) {
271
+ errors.push(h("div", { class: "note" }, `${name}: ${entry.error.code} — ${entry.error.message}`))
272
+ continue
273
+ }
274
+ for (const ns of entry.list.namespaces) if (ns.description) descriptions.set(ns.id, ns.description)
275
+ for (const c of entry.list.commands) {
276
+ if (!all.has(c.id)) all.set(c.id, { summary: c, visible: new Set() })
277
+ all.get(c.id).visible.add(name)
278
+ }
279
+ }
280
+ const filter = S.filter.trim().toLowerCase()
281
+ const groups = new Map()
282
+ for (const [id, entry] of [...all].sort(([a], [b]) => a.localeCompare(b))) {
283
+ if (filter && !id.toLowerCase().includes(filter) && !entry.summary.description.toLowerCase().includes(filter)) continue
284
+ const ns = id.includes(".") ? id.slice(0, id.lastIndexOf(".")) : ""
285
+ if (!groups.has(ns)) groups.set(ns, [])
286
+ groups.get(ns).push([id, entry])
287
+ }
288
+ const search = h("input", { type: "search", placeholder: "Filter commands", value: S.filter, id: "filter" })
289
+ search.addEventListener("input", () => {
290
+ S.filter = search.value
291
+ renderTree()
292
+ $("filter").focus()
293
+ })
294
+ const body = [...groups].map(([ns, items]) =>
295
+ h(
296
+ "details",
297
+ { open: true },
298
+ h("summary", {}, ns === "" ? "(root)" : ns, h("small", {}, descriptions.get(ns) || "")),
299
+ items.map(([id, entry]) => {
300
+ const hiddenFrom = names.filter((n) => S.lists[n] && S.lists[n].ok && !entry.visible.has(n))
301
+ return h(
302
+ "button",
303
+ { class: `cmd${id === S.selected ? " sel" : ""}${entry.visible.has(S.profile) ? "" : " dim"}`, title: entry.summary.description, onclick: () => select(id) },
304
+ h("span", { class: "name" }, ns === "" ? id : id.slice(ns.length + 1)),
305
+ hiddenFrom.map((n) => h("span", { class: "chip hidden", title: `Hidden from ${n} (expose)` }, n)),
306
+ safetyChips(entry.summary).slice(0, entry.summary.destructive ? 2 : 1),
307
+ entry.summary.requiresConfirmation || entry.summary.needsApproval ? h("span", { class: "chip c", title: "Needs confirmation" }, "✓?") : null,
308
+ )
309
+ }),
310
+ ),
311
+ )
312
+ $("tree").replaceChildren(search, ...errors, ...(body.length > 0 ? body : [h("div", { class: "empty" }, all.size === 0 ? "No commands visible." : "No match.")]))
313
+ }
314
+
315
+ /* ---------------------------------------------------------------- 명령어 · 입력 폼 */
316
+
317
+ function baseSchema(schema) {
318
+ if (!schema || typeof schema !== "object") return {}
319
+ const variants = schema.anyOf || schema.oneOf
320
+ if (Array.isArray(variants)) {
321
+ const rest = variants.filter((v) => v && v.type !== "null")
322
+ if (rest.length === 1) return { ...schema, ...baseSchema(rest[0]), anyOf: undefined, oneOf: undefined }
323
+ }
324
+ if (Array.isArray(schema.type)) {
325
+ const rest = schema.type.filter((t) => t !== "null")
326
+ if (rest.length === 1) return { ...schema, type: rest[0] }
327
+ }
328
+ return schema
329
+ }
330
+ const isScalar = (schema) => ["string", "number", "integer", "boolean"].includes(schema.type) || Array.isArray(schema.enum)
331
+
332
+ /** 필드 하나: { key, node, get() → 값 | undefined (비움), set(값) }. get 은 형식 오류에 throw */
333
+ function makeField(key, raw, required) {
334
+ const schema = baseSchema(raw)
335
+ const hint = [schema.description, schema.default !== undefined ? `default: ${JSON.stringify(schema.default)}` : null].filter(Boolean).join(" · ")
336
+ let control, get, set
337
+ if (Array.isArray(schema.enum)) {
338
+ control = h("select", {}, h("option", { value: "" }, "—"), schema.enum.map((v, i) => h("option", { value: String(i) }, String(v))))
339
+ get = () => (control.value === "" ? undefined : schema.enum[Number(control.value)])
340
+ set = (v) => (control.value = v === undefined ? "" : String(schema.enum.indexOf(v)))
341
+ } else if (schema.type === "boolean") {
342
+ control = h("select", {}, h("option", { value: "" }, "—"), h("option", { value: "true" }, "true"), h("option", { value: "false" }, "false"))
343
+ get = () => (control.value === "" ? undefined : control.value === "true")
344
+ set = (v) => (control.value = v === undefined ? "" : String(v))
345
+ } else if (schema.type === "number" || schema.type === "integer") {
346
+ control = h("input", { type: "number", step: schema.type === "integer" ? "1" : "any" })
347
+ get = () => (control.value === "" ? undefined : Number(control.value))
348
+ set = (v) => (control.value = v === undefined ? "" : String(v))
349
+ } else if (schema.type === "string") {
350
+ control = h("input", { type: "text", placeholder: schema.format || "" })
351
+ get = () => (control.value === "" ? undefined : control.value)
352
+ set = (v) => (control.value = v === undefined ? "" : String(v))
353
+ } else if (schema.type === "array" && isScalar(baseSchema(schema.items))) {
354
+ const item = baseSchema(schema.items)
355
+ control = h("textarea", { rows: 3, placeholder: "one item per line" })
356
+ get = () => {
357
+ if (control.value.trim() === "") return undefined
358
+ return control.value.split("\n").filter((l) => l !== "").map((l) => {
359
+ if (item.type === "number" || item.type === "integer") {
360
+ if (Number.isNaN(Number(l))) throw new Error(`${key}: "${l}" is not a number`)
361
+ return Number(l)
362
+ }
363
+ if (item.type === "boolean") return l.trim() === "true"
364
+ return l
365
+ })
366
+ }
367
+ set = (v) => (control.value = Array.isArray(v) ? v.join("\n") : "")
368
+ } else {
369
+ control = h("textarea", { rows: 3, placeholder: "JSON" })
370
+ get = () => {
371
+ if (control.value.trim() === "") return undefined
372
+ try {
373
+ return JSON.parse(control.value)
374
+ } catch {
375
+ throw new Error(`${key}: not valid JSON`)
376
+ }
377
+ }
378
+ set = (v) => (control.value = v === undefined ? "" : pretty(v))
379
+ }
380
+ control.id = `f-${key}`
381
+ const node = h(
382
+ "div",
383
+ { class: "field" },
384
+ h("label", { for: control.id }, key, required ? h("span", { class: "req", title: "required" }, " *") : null),
385
+ h("div", {}, control, hint ? h("small", {}, hint) : null),
386
+ )
387
+ return { key, node, get, set }
388
+ }
389
+
390
+ function buildForm(schema) {
391
+ const s = baseSchema(schema)
392
+ const props = s && s.type === "object" && s.properties ? s.properties : null
393
+ const fields = props ? Object.entries(props).map(([key, value]) => makeField(key, value, (s.required || []).includes(key))) : []
394
+ const json = h("textarea", { rows: 8, id: "input-json" })
395
+ json.value = "{}"
396
+ return { fields, json, structured: props !== null }
397
+ }
398
+ function readForm() {
399
+ if (S.mode === "json" || !form.structured) {
400
+ let value
401
+ try {
402
+ value = JSON.parse(form.json.value.trim() === "" ? "{}" : form.json.value)
403
+ } catch {
404
+ throw new Error("Input is not valid JSON")
405
+ }
406
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Input must be a JSON object")
407
+ return value
408
+ }
409
+ const out = {}
410
+ for (const f of form.fields) {
411
+ const v = f.get()
412
+ if (v !== undefined) out[f.key] = v
413
+ }
414
+ return out
415
+ }
416
+ function fillForm(input) {
417
+ const known = new Set(form.fields.map((f) => f.key))
418
+ const extra = Object.keys(input || {}).some((key) => !known.has(key))
419
+ for (const f of form.fields) f.set((input || {})[f.key])
420
+ form.json.value = pretty(input || {})
421
+ if (extra || !form.structured) setMode("json")
422
+ }
423
+ function setMode(mode) {
424
+ if (mode === S.mode) return
425
+ try {
426
+ const current = readForm()
427
+ S.mode = mode
428
+ if (mode === "json") form.json.value = pretty(current)
429
+ else for (const f of form.fields) f.set(current[f.key])
430
+ } catch (error) {
431
+ return toast(error.message)
432
+ }
433
+ renderCenter()
434
+ }
435
+
436
+ async function select(id, options = {}) {
437
+ S.selected = id
438
+ S.info = null
439
+ S.describeError = null
440
+ if (!options.keepCards) S.cards = []
441
+ if (location.hash.slice(1) !== id) history.replaceState(null, "", `#${id}`)
442
+ renderTree()
443
+ try {
444
+ const { info, visibleTo } = await getJson(`/api/command?id=${encodeURIComponent(id)}&as=${encodeURIComponent(involved().join(","))}`)
445
+ if (S.selected !== id) return
446
+ S.info = info
447
+ S.visibleTo = visibleTo
448
+ form = buildForm(info.inputSchema)
449
+ S.mode = form.structured ? "form" : "json"
450
+ } catch (error) {
451
+ if (S.selected !== id) return
452
+ S.describeError = (error.body && error.body.error) || { code: "ERROR", message: error.message }
453
+ S.visibleTo = (error.body && error.body.visibleTo) || {}
454
+ // 어느 프로필에도 보이지 않는 명령어도 호출해 볼 수 있다 (입력 = JSON)
455
+ form = buildForm(null)
456
+ S.mode = "json"
457
+ }
458
+ if (options.input) fillForm(options.input)
459
+ renderCenter()
460
+ }
461
+
462
+ function renderCenter() {
463
+ const root = $("center")
464
+ if (!S.selected) return root.replaceChildren(h("div", { class: "empty" }, "Select a command."))
465
+ const info = S.info
466
+ const names = involved()
467
+ const head = [h("h2", {}, S.selected)]
468
+ if (info) {
469
+ head.push(h("div", {}, info.description))
470
+ const approval = info.requiresConfirmation ? "required" : info.needsApproval === "conditional" ? "conditional" : "none"
471
+ head.push(
472
+ h(
473
+ "dl",
474
+ { class: "meta" },
475
+ h("dt", {}, "Safety"), h("dd", { class: "chips" }, safetyChips(info)),
476
+ h("dt", {}, "Approval"), h("dd", {}, approval === "none" ? "none" : approval === "required" ? "required — a person has to confirm each call" : "conditional — depends on the input"),
477
+ h("dt", {}, "Scopes"), h("dd", { class: "chips" }, info.scopes.length ? info.scopes.map((s) => h("span", { class: "chip" }, s)) : h("span", { class: "muted" }, "none")),
478
+ h("dt", {}, "Visible to"), h("dd", { class: "chips" }, names.map((n) => h("span", { class: `chip${S.visibleTo[n] ? "" : " hidden"}`, title: S.visibleTo[n] ? "visible" : "hidden (expose)" }, n))),
479
+ info.aliases.length ? [h("dt", {}, "Aliases"), h("dd", {}, info.aliases.join(", "))] : null,
480
+ info.tool ? [h("dt", {}, "Tool"), h("dd", {}, info.tool)] : null,
481
+ info.task ? [h("dt", {}, "Task"), h("dd", {}, `runs as a task (ttl ${info.task.ttlMs} ms)`)] : null,
482
+ info.errors.length ? [h("dt", {}, "Errors"), h("dd", { class: "chips" }, info.errors.map((e) => h("span", { class: "chip" }, e)))] : null,
483
+ ),
484
+ )
485
+ } else if (S.describeError) {
486
+ head.push(h("div", { class: "note" }, `${S.describeError.code}: ${S.describeError.message} — not visible to ${names.join(", ")}. You can still call it with JSON input to see what each caller gets.`))
487
+ } else head.push(h("div", { class: "muted" }, "Loading…"))
488
+
489
+ const examples = info && info.examples.length ? h("div", { class: "row" }, h("span", { class: "muted" }, "Examples"), info.examples.map((example, i) => h("button", { title: JSON.stringify(example), onclick: () => (fillForm(example), renderCenter()) }, `Fill example ${i + 1}`))) : null
490
+ const modes = form.structured
491
+ ? h("span", { class: "row" }, h("button", { class: `chip${S.mode === "form" ? " on" : ""}`, onclick: () => setMode("form") }, "Form"), h("button", { class: `chip${S.mode === "json" ? " on" : ""}`, onclick: () => setMode("json") }, "JSON"))
492
+ : null
493
+ const inputs = S.mode === "form" && form.structured ? (form.fields.length ? form.fields.map((f) => f.node) : [h("div", { class: "muted" }, "No input fields.")]) : [form.json]
494
+ const confirmed = h("input", { type: "checkbox", id: "confirmed", checked: S.confirmed, onchange: (event) => (S.confirmed = event.target.checked) })
495
+ const running = S.running !== null
496
+ const actions = h(
497
+ "div",
498
+ { class: "row actions" },
499
+ h("button", { class: "primary", id: "call", disabled: running, onclick: () => run([S.profile], confirmed.checked) }, `Call as ${S.profile}`),
500
+ S.compare.size > 0 ? h("button", { id: "call-all", disabled: running, onclick: () => run(names, confirmed.checked) }, `Call as ${names.join(", ")}`) : null,
501
+ h("label", { class: "row", title: 'Send the call with the confirmation mark (same as "--yes")' }, confirmed, "Confirmed by a person"),
502
+ running ? h("button", { id: "cancel", onclick: () => S.running.abort() }, "Cancel") : null,
503
+ )
504
+ root.replaceChildren(...head, h("h3", {}, "Input ", modes), ...(examples ? [examples] : []), ...inputs, actions, h("h3", {}, "Result"), h("div", { class: S.cards.length > 1 ? "grid" : "", id: "cards" }, S.cards.length ? S.cards.map(renderCard) : h("div", { class: "muted" }, "No call yet.")))
505
+ }
506
+
507
+ /* ---------------------------------------------------------------- 호출 · 결과 */
508
+
509
+ /** 이 호출의 감사 이벤트 = trace ID 가 같은 것 (inspector 가 요청에 실은 traceparent → 서버가 이벤트의 traceId 로 남긴다) */
510
+ function auditFor(record) {
511
+ return record.traceId ? S.audit.filter((e) => e.traceId === record.traceId) : []
512
+ }
513
+
514
+ function renderCard(card) {
515
+ const r = card.record
516
+ const head = [h("strong", {}, card.profile)]
517
+ if (!r) head.push(h("span", { class: "muted" }, card.status || "calling…"))
518
+ else {
519
+ head.push(h("span", { class: r.ok ? "ok" : "bad" }, r.ok ? "✓ ok" : `✗ ${r.error.code}${typeof r.error.reason === "string" ? ` (${r.error.reason})` : ""}`))
520
+ head.push(h("span", { class: "muted" }, `${r.durationMs} ms${r.confirmed ? " · confirmed" : ""}`))
521
+ }
522
+ const body = []
523
+ if (card.note) body.push(h("div", { class: "note" }, card.note))
524
+ const last = card.progress[card.progress.length - 1]
525
+ if (last && !r) {
526
+ const fill = h("i", {})
527
+ if (typeof last.progress === "number" && typeof last.total === "number" && last.total > 0) fill.style.width = `${Math.min(100, (last.progress / last.total) * 100)}%`
528
+ body.push(h("div", {}, `${last.progress !== undefined ? `[${last.progress}${last.total !== undefined ? `/${last.total}` : ""}] ` : ""}${last.message || ""}`), h("div", { class: "bar" }, fill))
529
+ }
530
+ if (card.task) body.push(h("div", { class: "muted" }, `task ${card.task.taskId}: ${card.task.status}${card.task.statusMessage ? ` — ${card.task.statusMessage}` : ""}`))
531
+ if (r) {
532
+ body.push(jsonBlock(r.ok ? r.value : r.error))
533
+ if (r.progress.length) body.push(h("details", {}, h("summary", {}, `Progress (${r.progress.length})`), jsonBlock(r.progress)))
534
+ if (r.inputs.length) body.push(h("details", {}, h("summary", {}, `Input requests (${r.inputs.length})`), jsonBlock(r.inputs)))
535
+ const events = auditFor(r)
536
+ if (events.length) body.push(h("details", { open: true }, h("summary", {}, `Audit events (${events.length})`), events.map((e) => jsonBlock(auditLine(e)))))
537
+ body.push(h("details", { class: "raw" }, h("summary", {}, `Raw requests and responses (${r.exchanges.length})`), r.exchanges.map((x) => [h("div", { class: "muted" }, `${x.request.method} ${x.rpc || ""} → ${x.response ? x.response.status : x.error || "no response"} (${x.durationMs} ms)`), jsonBlock({ request: x.request, response: x.response })])))
538
+ body.push(h("div", { class: "row" }, h("button", { onclick: () => addToScenario(r) }, "Add to scenario…"), h("button", { onclick: () => (fillForm(r.input), renderCenter()) }, "Refill input")))
539
+ }
540
+ return h("div", { class: "card" }, h("div", { class: "head" }, head), h("div", { class: "body" }, body))
541
+ }
542
+ const auditLine = (e) => {
543
+ const { at, command, outcome, surface, transport, actor, client, principal, approval, error, target, summary, input, traceId } = e
544
+ return { at, command, outcome, surface, transport, actor, client, principal, approval, error, target, summary, input, traceId }
545
+ }
546
+
547
+ /** 호출 1건 (프로필 하나). 승인 필요 (unconfirmed) = 확인 창 → 예 = 확인 표시와 함께 다시 호출 */
548
+ async function callOnce(id, input, profile, confirmed, signal) {
549
+ const card = { profile, progress: [], record: null }
550
+ S.cards.push(card)
551
+ renderCenter()
552
+ try {
553
+ for await (const event of stream("/api/call", { id, input, as: profile, confirmed }, signal)) {
554
+ if (event.type === "start") card.call = event.call
555
+ else if (event.type === "progress") card.progress.push(event.update)
556
+ else if (event.type === "task") card.task = event.task
557
+ else if (event.type === "input") {
558
+ card.status = "waiting for input…"
559
+ renderCenter()
560
+ const answer = await askInput(profile, id, event.request)
561
+ card.status = undefined
562
+ await postJson("/api/call/input", { call: card.call, request: event.request.id, answer })
563
+ } else if (event.type === "record") card.record = event.record
564
+ renderCenter()
565
+ }
566
+ } catch (error) {
567
+ if (!card.record) card.note = signal.aborted ? "Cancelled." : `Call failed: ${error.message}`
568
+ card.status = signal.aborted ? "cancelled" : "failed"
569
+ }
570
+ const error = card.record && !card.record.ok ? card.record.error : null
571
+ if (error && error.code === "APPROVAL_REQUIRED" && !signal.aborted) {
572
+ if (error.reason === "unconfirmed") {
573
+ const yes = await ask({
574
+ title: "Confirmation required",
575
+ body: [h("p", {}, `Run ${id} as ${profile} with this input?`), jsonBlock(input), h("p", { class: "muted" }, "The server asked for a person's confirmation. “Yes” sends the same call again with the confirmation mark.")],
576
+ buttons: [{ label: "No", value: false }, { label: "Yes, run it", value: true, primary: true }],
577
+ })
578
+ if (yes) {
579
+ card.note = "Not confirmed on the first try (APPROVAL_REQUIRED unconfirmed). Confirmed in the dialog → called again below."
580
+ renderCenter()
581
+ return callOnce(id, input, profile, true, signal)
582
+ }
583
+ card.note = "Not confirmed. The command did not run."
584
+ } else {
585
+ card.note = `Not retried: ${profile} cannot approve this command (reason: ${error.reason}). A person has to run it.`
586
+ }
587
+ }
588
+ renderCenter()
589
+ }
590
+
591
+ async function run(profiles, confirmed) {
592
+ let input
593
+ try {
594
+ input = readForm()
595
+ } catch (error) {
596
+ return toast(error.message)
597
+ }
598
+ const id = S.selected
599
+ S.cards = []
600
+ S.running = new AbortController()
601
+ renderCenter()
602
+ try {
603
+ for (const profile of profiles) {
604
+ if (S.running.signal.aborted) break
605
+ await callOnce(id, input, profile, confirmed, S.running.signal)
606
+ }
607
+ } finally {
608
+ S.running = null
609
+ renderCenter()
610
+ }
611
+ }
612
+
613
+ /* ---------------------------------------------------------------- 창 (확인 · 입력 요청) */
614
+
615
+ function ask({ title, body, buttons }) {
616
+ const dialog = $("dialog")
617
+ return new Promise((resolve) => {
618
+ let result
619
+ const finish = (value) => {
620
+ result = value
621
+ dialog.close()
622
+ }
623
+ dialog.replaceChildren(h("h4", {}, title), ...body, h("div", { class: "buttons" }, buttons.map((b) => h("button", { class: b.primary ? "primary" : "", "data-value": String(b.label), onclick: () => finish(typeof b.value === "function" ? b.value() : b.value) }, b.label))))
624
+ dialog.addEventListener("close", () => resolve(result), { once: true })
625
+ dialog.showModal()
626
+ })
627
+ }
628
+
629
+ async function askInput(profile, id, request) {
630
+ const who = h("p", { class: "muted" }, `${id} (as ${profile}) asks:`)
631
+ if (request.kind === "confirm") {
632
+ const yes = await ask({ title: "Confirm", body: [who, h("p", {}, request.message)], buttons: [{ label: "No", value: false }, { label: "Yes", value: true, primary: true }] })
633
+ return yes === true
634
+ }
635
+ if (request.kind === "url") {
636
+ const yes = await ask({
637
+ title: "Open a URL",
638
+ body: [who, h("p", {}, request.message), h("p", {}, h("a", { href: request.url, target: "_blank", rel: "noreferrer noopener" }, request.url))],
639
+ buttons: [{ label: "Decline", value: false }, { label: "I opened it", value: true, primary: true }],
640
+ })
641
+ return yes === true
642
+ }
643
+ const fields = buildForm(request.schema)
644
+ for (;;) {
645
+ const answer = await ask({
646
+ title: "Input requested",
647
+ body: [who, h("p", {}, request.message), ...(fields.structured ? fields.fields.map((f) => f.node) : [fields.json])],
648
+ buttons: [
649
+ { label: "Decline", value: false },
650
+ {
651
+ label: "Send",
652
+ primary: true,
653
+ value: () => {
654
+ try {
655
+ if (!fields.structured) return { action: "accept", content: JSON.parse(fields.json.value || "{}") }
656
+ const content = {}
657
+ for (const f of fields.fields) {
658
+ const v = f.get()
659
+ if (v !== undefined) content[f.key] = v
660
+ }
661
+ return { action: "accept", content }
662
+ } catch (error) {
663
+ toast(error.message)
664
+ return "retry"
665
+ }
666
+ },
667
+ },
668
+ ],
669
+ })
670
+ if (answer !== "retry") return answer === undefined ? false : answer
671
+ }
672
+ }
673
+
674
+ async function addToScenario(record) {
675
+ const name = h("input", { type: "text", value: S.scenarios.length ? S.scenarios[0].file : "scenario.json", list: "scenario-files" })
676
+ const picked = await ask({
677
+ title: "Add this call to a scenario",
678
+ body: [
679
+ h("p", { class: "muted" }, "The call and its observed result are appended as a step (inspect/scenarios/<name>.json)."),
680
+ name,
681
+ h("datalist", { id: "scenario-files" }, S.scenarios.map((s) => h("option", { value: s.file }))),
682
+ ],
683
+ buttons: [{ label: "Cancel", value: null }, { label: "Add", primary: true, value: () => name.value.trim() }],
684
+ })
685
+ if (!picked) return
686
+ try {
687
+ const result = await postJson("/api/scenarios/append", { file: picked.endsWith(".json") ? picked : `${picked}.json`, record: record.id })
688
+ toast(`Added to ${result.file} (${result.steps} steps)`)
689
+ await loadScenarios()
690
+ } catch (error) {
691
+ toast(error.message)
692
+ }
693
+ }
694
+
695
+ /* ---------------------------------------------------------------- 오른쪽: 기록 · 시나리오 · 감사 */
696
+
697
+ const two = (n) => String(n).padStart(2, "0")
698
+ const time = (iso) => {
699
+ const d = new Date(iso)
700
+ return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
701
+ }
702
+
703
+ function renderSide() {
704
+ const tabs = [["history", `History (${S.history.length})`], ["scenarios", `Scenarios (${S.scenarios.length})`], ["audit", `Audit (${S.audit.length})`]]
705
+ $("tabs").replaceChildren(...tabs.map(([id, label]) => h("button", { class: S.tab === id ? "on" : "", id: `tab-${id}`, onclick: () => ((S.tab = id), renderSide()) }, label)))
706
+ const panel = $("panel")
707
+ if (S.tab === "history") {
708
+ if (!S.history.length) return panel.replaceChildren(h("div", { class: "empty" }, "Calls made here, by scenarios, or in another tab show up here."))
709
+ panel.replaceChildren(
710
+ h("div", { class: "row" }, h("button", { onclick: clearHistory }, "Clear")),
711
+ ...[...S.history].reverse().map((r) =>
712
+ h(
713
+ "button",
714
+ { class: "item", onclick: () => showRecord(r) },
715
+ h("div", { class: "top" }, h("span", { class: r.ok ? "ok" : "bad" }, r.ok ? "✓" : "✗"), h("span", { class: "name" }, r.command), h("small", {}, time(r.at))),
716
+ h("small", {}, `as ${r.profile} · ${r.ok ? "ok" : `${r.error.code}${typeof r.error.reason === "string" ? ` (${r.error.reason})` : ""}`} · ${r.durationMs} ms`),
717
+ ),
718
+ ),
719
+ )
720
+ } else if (S.tab === "scenarios") {
721
+ if (!S.scenarios.length) return panel.replaceChildren(h("div", { class: "empty" }, "No scenario files in inspect/scenarios. Use “Add to scenario…” on a result to create one."))
722
+ panel.replaceChildren(
723
+ ...S.scenarios.map((s) => {
724
+ const run = S.scenarioRuns[s.file]
725
+ const steps = run ? run.steps : []
726
+ return h(
727
+ "div",
728
+ { class: "card" },
729
+ h(
730
+ "div",
731
+ { class: "head" },
732
+ h("strong", {}, s.name),
733
+ h("span", { class: "muted" }, `${s.file} · ${s.steps} steps`),
734
+ run && run.result ? h("span", { class: run.result.pass ? "ok" : "bad" }, run.result.pass ? "✓ passed" : "✗ failed") : null,
735
+ h("span", { class: "grow" }),
736
+ h("button", { disabled: Boolean(s.error) || (run && run.running), onclick: () => runScenario(s.file) }, run && run.running ? "Running…" : "Run"),
737
+ ),
738
+ s.description || s.error || steps.length
739
+ ? h(
740
+ "div",
741
+ { class: "body" },
742
+ s.description ? h("div", { class: "muted" }, s.description) : null,
743
+ s.error ? h("div", { class: "note" }, "Invalid file: ", s.error.join("; ")) : null,
744
+ s.agentSteps > 0 && !S.state.agent.available
745
+ ? h("div", { class: "note" }, `${s.agentSteps} agent step${s.agentSteps === 1 ? "" : "s"}: no agent is connected. Start with “mext inspect ui --agent <module>” to run them.`)
746
+ : null,
747
+ s.agentSteps > 0 && S.state.agent.available ? h("div", { class: "muted" }, `Agent: ${S.state.agent.name || "connected"}`) : null,
748
+ steps.length || (run && run.current)
749
+ ? h(
750
+ "ul",
751
+ { class: "steps" },
752
+ steps.map((st) => [
753
+ h("li", {}, h("span", { class: st.pass ? "ok" : "bad" }, st.pass ? "✓ " : "✗ "), `${st.step}. ${st.name} `, h("small", { class: "muted" }, `as ${st.as} · ${st.durationMs} ms`)),
754
+ st.failures.map((f) => h("li", { class: "fail" }, `${f.path}: expected ${JSON.stringify(f.expected)}, got ${JSON.stringify(f.actual)}`)),
755
+ st.outcome && Array.isArray(st.outcome.events) ? h("li", { class: "agent" }, agentDetails(st)) : null,
756
+ ]),
757
+ run && run.current ? h("li", { class: "muted" }, `… ${run.current.step}. ${run.current.name} `, h("small", {}, run.current.as === "agent" ? "waiting for the agent" : "running")) : null,
758
+ )
759
+ : null,
760
+ )
761
+ : null,
762
+ )
763
+ }),
764
+ )
765
+ } else {
766
+ const audit = S.state.audit
767
+ if (!audit.available) return panel.replaceChildren(h("div", { class: "note" }, "Audit events are not available for this target. ", audit.reason || ""))
768
+ if (!S.audit.length) return panel.replaceChildren(h("div", { class: "empty" }, "Waiting for audit events. Every call to the server shows up here (including calls made by agents)."))
769
+ panel.replaceChildren(
770
+ ...[...S.audit].reverse().map((e) =>
771
+ h(
772
+ "details",
773
+ { class: "item" },
774
+ h(
775
+ "summary",
776
+ {},
777
+ h("span", { class: e.outcome === "success" ? "ok" : "bad" }, e.outcome),
778
+ " ",
779
+ h("code", {}, e.command),
780
+ " ",
781
+ h("small", {}, `${time(e.at)} · ${e.surface}${e.actor ? ` · ${e.actor.kind}:${e.actor.id}` : ""}${e.client ? ` · ${e.client.name}` : ""}${e.error ? ` · ${e.error.code}` : ""}${e.approval ? ` · approval ${e.approval.required ? (e.approval.confirmed ? "confirmed" : "not confirmed") : "not required"}` : ""}`),
782
+ ),
783
+ jsonBlock(e),
784
+ ),
785
+ ),
786
+ )
787
+ }
788
+ }
789
+
790
+ async function showRecord(record) {
791
+ await select(record.command, { input: record.input, keepCards: true })
792
+ S.cards = [{ profile: record.profile, progress: [], record }]
793
+ renderCenter()
794
+ }
795
+ async function clearHistory() {
796
+ await getJson("/api/history", { method: "DELETE" })
797
+ S.history = []
798
+ renderSide()
799
+ }
800
+ /** agent 단계의 결과: 지시문 · 에이전트가 한 말 · 에이전트 쪽 도구 호출 · 서버가 본 호출 (감사 이벤트). 판정은 서버가 본 호출로만 한다 */
801
+ function agentDetails(st) {
802
+ const o = st.outcome
803
+ const said = o.messages.length ? o.messages.map((m) => h("div", { class: "said" }, m)) : h("div", { class: "muted" }, "(nothing)")
804
+ const tools = o.tools.map((t) =>
805
+ h("details", {}, h("summary", {}, h("span", { class: t.ok === false ? "bad" : "ok" }, t.ok === false ? "✗ " : "✓ "), h("code", {}, t.name)), jsonBlock({ input: t.input, output: t.output })),
806
+ )
807
+ const saw = o.events.map((e) =>
808
+ h(
809
+ "details",
810
+ {},
811
+ h("summary", {}, h("span", { class: e.outcome === "success" ? "ok" : "bad" }, e.outcome), " ", h("code", {}, e.command), h("small", { class: "muted" }, ` ${[e.actor ? `${e.actor.kind}:${e.actor.id}` : null, e.client ? e.client.name : null, e.error ? e.error.code : null].filter(Boolean).join(" · ")}`)),
812
+ jsonBlock(auditLine(e)),
813
+ ),
814
+ )
815
+ return h(
816
+ "details",
817
+ { open: !st.pass },
818
+ h("summary", {}, `Agent · said ${o.messages.length} · tool calls ${o.tools.length} · server saw ${o.events.length}`),
819
+ o.ok ? null : h("div", { class: "note" }, `The agent did not finish: ${o.problem}`),
820
+ h("h5", {}, "Instruction"),
821
+ jsonBlock(o.instruction),
822
+ h("h5", {}, "Server saw (audit events — the pass criterion)"),
823
+ saw.length ? saw : h("div", { class: "muted" }, "No call from the agent reached the server."),
824
+ h("h5", {}, "Agent said"),
825
+ said,
826
+ h("h5", {}, `Agent's own tool calls (${o.tools.length}, not used for pass / fail)`),
827
+ tools.length ? tools : h("div", { class: "muted" }, "(none reported)"),
828
+ )
829
+ }
830
+
831
+ async function loadScenarios() {
832
+ S.scenarios = await getJson("/api/scenarios")
833
+ renderSide()
834
+ }
835
+ async function runScenario(file) {
836
+ const run = (S.scenarioRuns[file] = { running: true, steps: [], result: null, current: null })
837
+ renderSide()
838
+ try {
839
+ for await (const event of stream("/api/scenarios/run", { file })) {
840
+ if (event.type === "step-start") run.current = { step: event.step, name: event.name, as: event.as }
841
+ else if (event.type === "step") {
842
+ run.current = null
843
+ run.steps.push(event.step)
844
+ }
845
+ else if (event.type === "result") run.result = event.result
846
+ renderSide()
847
+ }
848
+ } catch (error) {
849
+ toast(error.message)
850
+ }
851
+ run.running = false
852
+ run.current = null
853
+ renderSide()
854
+ }
855
+
856
+ /** 호출 기록 · 감사 이벤트 스트림 (끊기면 다시) */
857
+ async function listen() {
858
+ for (;;) {
859
+ try {
860
+ const response = await api("/api/events")
861
+ for await (const event of lines(response)) {
862
+ if (event.type === "record") {
863
+ if (!S.history.some((r) => r.id === event.record.id)) S.history.push(event.record)
864
+ renderSide()
865
+ } else if (event.type === "audit") {
866
+ S.audit.push(event.event)
867
+ renderSide()
868
+ if (S.cards.some((c) => c.record)) renderCenter()
869
+ }
870
+ }
871
+ } catch {
872
+ // 다시 연결
873
+ }
874
+ await new Promise((resolve) => setTimeout(resolve, 1000))
875
+ }
876
+ }
877
+
878
+ /* ---------------------------------------------------------------- 시작 */
879
+
880
+ async function reload() {
881
+ renderHeader()
882
+ const names = involved()
883
+ const lists = await getJson(`/api/commands?as=${encodeURIComponent(names.join(","))}`)
884
+ S.lists = Object.fromEntries(lists.map((entry) => [entry.profile, entry]))
885
+ renderTree()
886
+ if (S.selected) await select(S.selected, { keepCards: true, input: safeRead() })
887
+ else renderCenter()
888
+ }
889
+ function safeRead() {
890
+ try {
891
+ return S.info ? readForm() : undefined
892
+ } catch {
893
+ return undefined
894
+ }
895
+ }
896
+
897
+ async function start() {
898
+ S.state = await getJson("/api/state")
899
+ const usable = S.state.profiles.filter((p) => p.available)
900
+ S.profile = (usable.find((p) => p.name === S.state.default) || usable[0] || S.state.profiles[0]).name
901
+ ;[S.history, S.audit] = await Promise.all([getJson("/api/history"), getJson("/api/audit")])
902
+ renderSide()
903
+ await loadScenarios()
904
+ S.selected = decodeURIComponent(location.hash.slice(1)) || null
905
+ await reload()
906
+ void listen()
907
+ }
908
+ window.addEventListener("hashchange", () => {
909
+ const id = decodeURIComponent(location.hash.slice(1))
910
+ if (id && id !== S.selected) void select(id)
911
+ })
912
+ start().catch((error) => {
913
+ $("center").replaceChildren(h("div", { class: "note" }, `Could not load: ${error.message}`))
914
+ })
915
+ </script>
916
+ </body>
917
+ </html>