arkaos 3.55.0 → 3.57.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.55.0
1
+ 3.57.0
@@ -283,15 +283,28 @@ function onCloned(agentId: string) {
283
283
  }
284
284
 
285
285
  // PR88a v3.23.0 — Compare with linked agent.
286
- const compareWithOptions = computed(() =>
287
- linkedAgentIds.value.map((aid) => ({
288
- label: `Compare with ${aid}`,
289
- icon: 'i-lucide-columns-2',
286
+ // PR96c v3.57.0 also compare with another persona.
287
+ const { data: otherPersonasData } = fetchApi<{ personas: Array<{ id: string, name: string }> }>(
288
+ '/api/personas',
289
+ )
290
+ const compareWithOptions = computed(() => {
291
+ const agentOpts = linkedAgentIds.value.map((aid) => ({
292
+ label: `Compare with agent ${aid}`,
293
+ icon: 'i-lucide-user',
290
294
  onSelect: () => navigateTo(
291
295
  `/personas/compare-with-agent?persona=${personaId}&agent=${aid}`,
292
296
  ),
293
- })),
294
- )
297
+ }))
298
+ const personaOpts = (otherPersonasData.value?.personas ?? [])
299
+ .filter((p) => p.id !== personaId)
300
+ .slice(0, 30)
301
+ .map((p) => ({
302
+ label: `Compare with persona ${p.name}`,
303
+ icon: 'i-lucide-user-plus',
304
+ onSelect: () => navigateTo(`/personas/compare?a=${personaId}&b=${p.id}`),
305
+ }))
306
+ return [...agentOpts, ...personaOpts]
307
+ })
295
308
 
296
309
  // PR84c v3.9.0 — Auto-fill empty lists in one go.
297
310
  const autofilling = ref(false)
@@ -0,0 +1,213 @@
1
+ <script setup lang="ts">
2
+ // PR96c v3.57.0 — Compare two personas side-by-side.
3
+ //
4
+ // Driven by `?a=p1&b=p2`. Mirrors the agents/compare layout but
5
+ // adapts to the persona schema (flat mental_models, no department).
6
+
7
+ const route = useRoute()
8
+ const { fetchApi } = useApi()
9
+
10
+ const ids = computed<string[]>(() => {
11
+ const raw = [route.query.a, route.query.b]
12
+ return raw.map((v) => String(v ?? '').trim()).filter(Boolean).slice(0, 2)
13
+ })
14
+
15
+ interface PersonaDetail {
16
+ id: string
17
+ name?: string
18
+ title?: string
19
+ source?: string
20
+ tagline?: string
21
+ mbti?: string
22
+ disc?: { primary?: string, secondary?: string }
23
+ enneagram?: { type?: number, wing?: number }
24
+ big_five?: {
25
+ openness?: number
26
+ conscientiousness?: number
27
+ extraversion?: number
28
+ agreeableness?: number
29
+ neuroticism?: number
30
+ }
31
+ mental_models?: string[]
32
+ expertise_domains?: string[]
33
+ frameworks?: string[]
34
+ key_quotes?: string[]
35
+ communication?: { tone?: string, vocabulary_level?: string, avoid?: string[] }
36
+ bio_md?: string
37
+ error?: string
38
+ }
39
+
40
+ const { data: a, status: aStatus } = fetchApi<PersonaDetail>(
41
+ () => ids.value[0] ? `/api/personas/${ids.value[0]}` : '',
42
+ )
43
+ const { data: b, status: bStatus } = fetchApi<PersonaDetail>(
44
+ () => ids.value[1] ? `/api/personas/${ids.value[1]}` : '',
45
+ )
46
+
47
+ const loading = computed(() => aStatus.value === 'pending' || bStatus.value === 'pending')
48
+ const errorMsg = computed(() => {
49
+ if (ids.value.length < 2) return 'Pass two persona ids via ?a=p1&b=p2'
50
+ if (a.value?.error) return `Left: ${a.value.error}`
51
+ if (b.value?.error) return `Right: ${b.value.error}`
52
+ return null
53
+ })
54
+
55
+ function diffClass(left: unknown, right: unknown): string {
56
+ return left !== right ? 'bg-yellow-500/10 border-yellow-500/30' : ''
57
+ }
58
+ function listDiffClass(left: unknown[] | undefined, right: unknown[] | undefined): string {
59
+ const x = JSON.stringify([...(left ?? [])].sort())
60
+ const y = JSON.stringify([...(right ?? [])].sort())
61
+ return x !== y ? 'bg-yellow-500/10 border-yellow-500/30' : ''
62
+ }
63
+
64
+ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const
65
+ </script>
66
+
67
+ <template>
68
+ <UDashboardPanel id="personas-compare">
69
+ <template #header>
70
+ <UDashboardNavbar title="Compare personas">
71
+ <template #leading>
72
+ <UButton icon="i-lucide-arrow-left" variant="ghost" size="sm" to="/personas" aria-label="Back" />
73
+ </template>
74
+ <template #trailing>
75
+ <UBadge label="2-way" variant="subtle" size="sm" />
76
+ </template>
77
+ </UDashboardNavbar>
78
+ </template>
79
+
80
+ <template #body>
81
+ <div v-if="errorMsg" class="p-6 text-center text-sm text-error">
82
+ {{ errorMsg }}
83
+ </div>
84
+ <div v-else-if="loading" class="p-6 text-center text-sm text-muted">
85
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin inline" /> Loading…
86
+ </div>
87
+ <div v-else-if="a && b" class="space-y-4 max-w-6xl">
88
+ <section class="grid grid-cols-2 gap-3">
89
+ <NuxtLink :to="`/personas/${a.id}`" class="rounded-lg border border-default p-4 hover:border-primary/40">
90
+ <p class="text-xs text-muted uppercase tracking-wide">Left</p>
91
+ <h2 class="text-xl font-bold">{{ a.name }}</h2>
92
+ <p class="text-sm text-muted">{{ a.title || '—' }}</p>
93
+ </NuxtLink>
94
+ <NuxtLink :to="`/personas/${b.id}`" class="rounded-lg border border-default p-4 hover:border-primary/40">
95
+ <p class="text-xs text-muted uppercase tracking-wide">Right</p>
96
+ <h2 class="text-xl font-bold">{{ b.name }}</h2>
97
+ <p class="text-sm text-muted">{{ b.title || '—' }}</p>
98
+ </NuxtLink>
99
+ </section>
100
+
101
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Behavioural DNA</h3>
102
+ <div class="grid grid-cols-2 gap-3">
103
+ <div :class="['rounded-lg border p-3', diffClass(a.mbti, b.mbti)]">
104
+ <p class="text-xs text-muted">MBTI</p>
105
+ <p class="text-lg font-mono font-bold">{{ a.mbti ?? '—' }}</p>
106
+ </div>
107
+ <div :class="['rounded-lg border p-3', diffClass(a.mbti, b.mbti)]">
108
+ <p class="text-xs text-muted">MBTI</p>
109
+ <p class="text-lg font-mono font-bold">{{ b.mbti ?? '—' }}</p>
110
+ </div>
111
+ <div :class="['rounded-lg border p-3', diffClass(`${a.disc?.primary}/${a.disc?.secondary}`, `${b.disc?.primary}/${b.disc?.secondary}`)]">
112
+ <p class="text-xs text-muted">DISC</p>
113
+ <p class="text-lg font-mono font-bold">{{ a.disc?.primary ?? '?' }}/{{ a.disc?.secondary ?? '?' }}</p>
114
+ </div>
115
+ <div :class="['rounded-lg border p-3', diffClass(`${a.disc?.primary}/${a.disc?.secondary}`, `${b.disc?.primary}/${b.disc?.secondary}`)]">
116
+ <p class="text-xs text-muted">DISC</p>
117
+ <p class="text-lg font-mono font-bold">{{ b.disc?.primary ?? '?' }}/{{ b.disc?.secondary ?? '?' }}</p>
118
+ </div>
119
+ <div :class="['rounded-lg border p-3', diffClass(`${a.enneagram?.type}w${a.enneagram?.wing}`, `${b.enneagram?.type}w${b.enneagram?.wing}`)]">
120
+ <p class="text-xs text-muted">Enneagram</p>
121
+ <p class="text-lg font-mono font-bold">{{ a.enneagram?.type ?? '?' }}w{{ a.enneagram?.wing ?? '?' }}</p>
122
+ </div>
123
+ <div :class="['rounded-lg border p-3', diffClass(`${a.enneagram?.type}w${a.enneagram?.wing}`, `${b.enneagram?.type}w${b.enneagram?.wing}`)]">
124
+ <p class="text-xs text-muted">Enneagram</p>
125
+ <p class="text-lg font-mono font-bold">{{ b.enneagram?.type ?? '?' }}w{{ b.enneagram?.wing ?? '?' }}</p>
126
+ </div>
127
+ </div>
128
+
129
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Big Five (OCEAN)</h3>
130
+ <div class="space-y-1">
131
+ <div v-for="k in bigFiveKeys" :key="k" class="grid grid-cols-2 gap-3">
132
+ <div :class="['rounded-lg border p-2 flex items-center gap-3', diffClass(a.big_five?.[k], b.big_five?.[k])]">
133
+ <span class="text-xs text-muted w-36 shrink-0 capitalize">{{ k }}</span>
134
+ <span class="font-mono text-sm">{{ a.big_five?.[k] ?? '—' }}</span>
135
+ </div>
136
+ <div :class="['rounded-lg border p-2 flex items-center gap-3', diffClass(a.big_five?.[k], b.big_five?.[k])]">
137
+ <span class="text-xs text-muted w-36 shrink-0 capitalize">{{ k }}</span>
138
+ <span class="font-mono text-sm">{{ b.big_five?.[k] ?? '—' }}</span>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Expertise domains</h3>
144
+ <div class="grid grid-cols-2 gap-3">
145
+ <div :class="['rounded-lg border p-3', listDiffClass(a.expertise_domains, b.expertise_domains)]">
146
+ <ul class="list-disc list-inside text-sm space-y-1">
147
+ <li v-for="d in a.expertise_domains" :key="d">{{ d }}</li>
148
+ <li v-if="!a.expertise_domains?.length" class="list-none text-muted italic">none</li>
149
+ </ul>
150
+ </div>
151
+ <div :class="['rounded-lg border p-3', listDiffClass(a.expertise_domains, b.expertise_domains)]">
152
+ <ul class="list-disc list-inside text-sm space-y-1">
153
+ <li v-for="d in b.expertise_domains" :key="d">{{ d }}</li>
154
+ <li v-if="!b.expertise_domains?.length" class="list-none text-muted italic">none</li>
155
+ </ul>
156
+ </div>
157
+ </div>
158
+
159
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Mental models</h3>
160
+ <div class="grid grid-cols-2 gap-3">
161
+ <div :class="['rounded-lg border p-3', listDiffClass(a.mental_models, b.mental_models)]">
162
+ <ul class="list-disc list-inside text-sm space-y-1">
163
+ <li v-for="m in a.mental_models" :key="m">{{ m }}</li>
164
+ <li v-if="!a.mental_models?.length" class="list-none text-muted italic">none</li>
165
+ </ul>
166
+ </div>
167
+ <div :class="['rounded-lg border p-3', listDiffClass(a.mental_models, b.mental_models)]">
168
+ <ul class="list-disc list-inside text-sm space-y-1">
169
+ <li v-for="m in b.mental_models" :key="m">{{ m }}</li>
170
+ <li v-if="!b.mental_models?.length" class="list-none text-muted italic">none</li>
171
+ </ul>
172
+ </div>
173
+ </div>
174
+
175
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Frameworks</h3>
176
+ <div class="grid grid-cols-2 gap-3">
177
+ <div :class="['rounded-lg border p-3', listDiffClass(a.frameworks, b.frameworks)]">
178
+ <ul class="list-disc list-inside text-sm space-y-1">
179
+ <li v-for="f in a.frameworks" :key="f">{{ f }}</li>
180
+ <li v-if="!a.frameworks?.length" class="list-none text-muted italic">none</li>
181
+ </ul>
182
+ </div>
183
+ <div :class="['rounded-lg border p-3', listDiffClass(a.frameworks, b.frameworks)]">
184
+ <ul class="list-disc list-inside text-sm space-y-1">
185
+ <li v-for="f in b.frameworks" :key="f">{{ f }}</li>
186
+ <li v-if="!b.frameworks?.length" class="list-none text-muted italic">none</li>
187
+ </ul>
188
+ </div>
189
+ </div>
190
+
191
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Bio (Markdown)</h3>
192
+ <TextDiff
193
+ :left="a.bio_md || ''"
194
+ :right="b.bio_md || ''"
195
+ :left-label="a.name || a.id"
196
+ :right-label="b.name || b.id"
197
+ />
198
+
199
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Communication tone</h3>
200
+ <TextDiff
201
+ :left="a.communication?.tone || ''"
202
+ :right="b.communication?.tone || ''"
203
+ :left-label="a.name || a.id"
204
+ :right-label="b.name || b.id"
205
+ />
206
+
207
+ <p class="text-xs text-muted pt-4 italic">
208
+ Yellow tint = different. Red removed, green added.
209
+ </p>
210
+ </div>
211
+ </template>
212
+ </UDashboardPanel>
213
+ </template>
@@ -9,10 +9,18 @@
9
9
  // instead ships a controlled command runner with allowlist + capped
10
10
  // output. xterm.js-style PTY can be a later upgrade if needed.
11
11
 
12
+ interface CommandArg {
13
+ name: string
14
+ label: string
15
+ choices: string[]
16
+ default: string
17
+ }
18
+
12
19
  interface CommandEntry {
13
20
  id: string
14
21
  label: string
15
22
  description: string
23
+ args?: CommandArg[]
16
24
  }
17
25
 
18
26
  interface ExecResult {
@@ -41,12 +49,33 @@ const commands = computed<CommandEntry[]>(() => cmdData.value?.commands ?? [])
41
49
  const running = ref<string | null>(null)
42
50
  const history = ref<HistoryEntry[]>([])
43
51
 
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>>>({})
55
+
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
+
44
66
  async function run(cmd: CommandEntry) {
45
67
  running.value = cmd.id
68
+ ensureArgState(cmd)
46
69
  try {
47
70
  const res = await $fetch<ExecResult & { error?: string }>(
48
71
  `${apiBase}/api/terminal/exec`,
49
- { method: 'POST', body: { command_id: cmd.id } },
72
+ {
73
+ method: 'POST',
74
+ body: {
75
+ command_id: cmd.id,
76
+ args: cmd.args ? argState[cmd.id] : undefined,
77
+ },
78
+ },
50
79
  )
51
80
  if (res.error) throw new Error(res.error)
52
81
  history.value = [
@@ -139,22 +168,47 @@ function relative(iso: string): string {
139
168
  </p>
140
169
  </div>
141
170
  </template>
142
- <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
143
- <UButton
171
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
172
+ <div
144
173
  v-for="cmd in commands"
145
174
  :key="cmd.id"
146
- :label="cmd.label"
147
- :description="cmd.description"
148
- icon="i-lucide-terminal"
149
- variant="soft"
150
- color="primary"
151
- size="sm"
152
- block
153
- class="justify-start"
154
- :loading="running === cmd.id"
155
- :disabled="running !== null && running !== cmd.id"
156
- @click="run(cmd)"
157
- />
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>
158
212
  </div>
159
213
  </UCard>
160
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.55.0",
3
+ "version": "3.57.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.55.0"
3
+ version = "3.57.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"}
@@ -2024,9 +2024,17 @@ TERMINAL_ALLOWLIST: list[dict] = [
2024
2024
  },
2025
2025
  {
2026
2026
  "id": "git-log",
2027
- "label": "git log (10)",
2028
- "cmd": ["git", "log", "-10", "--oneline"],
2029
- "description": "Most recent 10 commits.",
2027
+ "label": "git log",
2028
+ "cmd_template": ["git", "log", "-{count}", "--oneline"],
2029
+ "args": [
2030
+ {
2031
+ "name": "count",
2032
+ "label": "Commits to show",
2033
+ "choices": ["5", "10", "20", "50"],
2034
+ "default": "10",
2035
+ },
2036
+ ],
2037
+ "description": "Most recent N commits (operator picks N).",
2030
2038
  },
2031
2039
  {
2032
2040
  "id": "npm-version",
@@ -2046,6 +2054,20 @@ TERMINAL_ALLOWLIST: list[dict] = [
2046
2054
  "cmd": ["ls", "-la"],
2047
2055
  "description": "List files in the project root.",
2048
2056
  },
2057
+ {
2058
+ "id": "arka-costs",
2059
+ "label": "ArkaOS costs",
2060
+ "cmd_template": ["python3", "-m", "core.runtime.llm_cost_telemetry_cli", "{period}"],
2061
+ "args": [
2062
+ {
2063
+ "name": "period",
2064
+ "label": "Period",
2065
+ "choices": ["today", "week", "month", "all"],
2066
+ "default": "today",
2067
+ },
2068
+ ],
2069
+ "description": "LLM cost summary for the selected period.",
2070
+ },
2049
2071
  ]
2050
2072
 
2051
2073
  _TERMINAL_TIMEOUT_S = 15
@@ -2054,14 +2076,41 @@ _TERMINAL_MAX_OUTPUT = 20_000 # chars — both stdout and stderr capped
2054
2076
 
2055
2077
  @app.get("/api/terminal/commands")
2056
2078
  def terminal_commands():
2057
- """List allowlisted commands the dashboard terminal may run."""
2058
- return {
2059
- "commands": [
2060
- {"id": c["id"], "label": c["label"], "description": c["description"]}
2061
- for c in TERMINAL_ALLOWLIST
2062
- ],
2063
- "total": len(TERMINAL_ALLOWLIST),
2064
- }
2079
+ """List allowlisted commands the dashboard terminal may run.
2080
+
2081
+ PR96b v3.56.0 — includes the `args` schema (label / choices /
2082
+ default) when an entry has parameters. The raw cmd / cmd_template
2083
+ is NEVER returned — defence in depth.
2084
+ """
2085
+ out: list[dict] = []
2086
+ for c in TERMINAL_ALLOWLIST:
2087
+ out.append({
2088
+ "id": c["id"],
2089
+ "label": c["label"],
2090
+ "description": c["description"],
2091
+ "args": _safe_args_schema(c.get("args")),
2092
+ })
2093
+ return {"commands": out, "total": len(out)}
2094
+
2095
+
2096
+ def _safe_args_schema(args) -> list[dict]:
2097
+ """Sanitised arg schema for the public API — never leaks cmd/template."""
2098
+ if not isinstance(args, list):
2099
+ return []
2100
+ out: list[dict] = []
2101
+ for a in args:
2102
+ if not isinstance(a, dict):
2103
+ continue
2104
+ choices = a.get("choices")
2105
+ if not isinstance(choices, list) or not choices:
2106
+ continue
2107
+ out.append({
2108
+ "name": str(a.get("name") or ""),
2109
+ "label": str(a.get("label") or a.get("name") or ""),
2110
+ "choices": [str(x) for x in choices],
2111
+ "default": str(a.get("default") or choices[0]),
2112
+ })
2113
+ return out
2065
2114
 
2066
2115
 
2067
2116
  @app.post("/api/terminal/exec")
@@ -2080,11 +2129,20 @@ def terminal_exec(body: dict):
2080
2129
  entry = next((c for c in TERMINAL_ALLOWLIST if c["id"] == cid), None)
2081
2130
  if entry is None:
2082
2131
  return {"error": f"command '{cid}' is not on the allowlist"}
2132
+
2133
+ # PR96b v3.56.0 — resolve template + args, or fall back to fixed cmd.
2134
+ if "cmd_template" in entry:
2135
+ argv, err = _resolve_cmd_template(entry, body.get("args") or {})
2136
+ if err:
2137
+ return {"error": err}
2138
+ else:
2139
+ argv = entry["cmd"]
2140
+
2083
2141
  import time
2084
2142
  started = time.monotonic()
2085
2143
  try:
2086
2144
  result = subprocess.run(
2087
- entry["cmd"],
2145
+ argv,
2088
2146
  cwd=str(ARKAOS_ROOT),
2089
2147
  capture_output=True,
2090
2148
  text=True,
@@ -2097,7 +2155,7 @@ def terminal_exec(body: dict):
2097
2155
  "stderr": f"command timed out after {_TERMINAL_TIMEOUT_S}s",
2098
2156
  "exit_code": -1,
2099
2157
  "duration_ms": int(_TERMINAL_TIMEOUT_S * 1000),
2100
- "command": " ".join(entry["cmd"]),
2158
+ "command": " ".join(argv),
2101
2159
  }
2102
2160
  except OSError as exc:
2103
2161
  return {
@@ -2105,7 +2163,7 @@ def terminal_exec(body: dict):
2105
2163
  "stderr": f"command failed to launch: {exc}",
2106
2164
  "exit_code": -1,
2107
2165
  "duration_ms": int((time.monotonic() - started) * 1000),
2108
- "command": " ".join(entry["cmd"]),
2166
+ "command": " ".join(argv),
2109
2167
  }
2110
2168
  duration_ms = int((time.monotonic() - started) * 1000)
2111
2169
  return {
@@ -2113,10 +2171,49 @@ def terminal_exec(body: dict):
2113
2171
  "stderr": (result.stderr or "")[:_TERMINAL_MAX_OUTPUT],
2114
2172
  "exit_code": result.returncode,
2115
2173
  "duration_ms": duration_ms,
2116
- "command": " ".join(entry["cmd"]),
2174
+ "command": " ".join(argv),
2117
2175
  }
2118
2176
 
2119
2177
 
2178
+ def _resolve_cmd_template(entry: dict, supplied: dict) -> tuple[list[str], "str | None"]:
2179
+ """Substitute `{name}` placeholders in cmd_template with validated args.
2180
+
2181
+ Returns (argv, None) on success, ([], error_msg) on validation failure.
2182
+ Anything supplied that isn't in the schema's choices is rejected.
2183
+ Unknown arg names are also rejected — no silent passthrough.
2184
+ """
2185
+ schema = entry.get("args") or []
2186
+ if not isinstance(schema, list):
2187
+ return [], "command has invalid args schema"
2188
+ by_name: dict[str, dict] = {a["name"]: a for a in schema if isinstance(a, dict)}
2189
+ chosen: dict[str, str] = {}
2190
+ for arg_def in schema:
2191
+ name = arg_def["name"]
2192
+ s = supplied.get(name)
2193
+ if s is None:
2194
+ chosen[name] = str(arg_def.get("default") or arg_def["choices"][0])
2195
+ continue
2196
+ s_str = str(s)
2197
+ if s_str not in arg_def["choices"]:
2198
+ return [], (
2199
+ f"arg '{name}'='{s_str}' is not in the allowed choices "
2200
+ f"({', '.join(arg_def['choices'])})"
2201
+ )
2202
+ chosen[name] = s_str
2203
+ for k in supplied:
2204
+ if k not in by_name:
2205
+ return [], f"unknown arg '{k}'"
2206
+ argv: list[str] = []
2207
+ for tok in entry["cmd_template"]:
2208
+ if not isinstance(tok, str):
2209
+ continue
2210
+ out_tok = tok
2211
+ for name, val in chosen.items():
2212
+ out_tok = out_tok.replace(f"{{{name}}}", val)
2213
+ argv.append(out_tok)
2214
+ return argv, None
2215
+
2216
+
2120
2217
  @app.put("/api/workflows/{workflow_id}/yaml")
2121
2218
  def workflow_update_yaml(workflow_id: str, body: dict):
2122
2219
  """PR94d v3.50.0 — overwrite a workflow's YAML file in place.