create-harness-vibe-coding 0.8.18 → 0.8.19

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 (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -2
  4. package/src/index.js +383 -267
  5. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  6. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  7. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  8. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  9. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  10. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  11. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  12. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  13. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  14. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  15. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  16. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  17. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  18. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  19. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  20. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  21. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  22. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  23. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  24. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  25. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  26. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  27. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  28. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  29. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  30. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  31. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  32. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  33. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  34. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  35. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  36. package/src/ui/dist/index.html +18 -0
  37. package/src/ui/index.html +17 -0
  38. package/src/ui/package.json +33 -0
  39. package/src/ui/pnpm-lock.yaml +1582 -0
  40. package/src/ui/pnpm-workspace.yaml +2 -0
  41. package/src/ui/src/App.tsx +86 -0
  42. package/src/ui/src/api.ts +93 -0
  43. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  44. package/src/ui/src/components/Footer.tsx +69 -0
  45. package/src/ui/src/components/Header.tsx +175 -0
  46. package/src/ui/src/components/LoadingView.tsx +22 -0
  47. package/src/ui/src/components/RolesRoute.tsx +169 -0
  48. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  49. package/src/ui/src/components/TaskList.tsx +388 -0
  50. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  51. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  52. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  53. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  54. package/src/ui/src/index.css +281 -0
  55. package/src/ui/src/main.tsx +11 -0
  56. package/src/ui/src/types.ts +108 -0
  57. package/src/ui/tsconfig.json +21 -0
  58. package/src/ui/vite.config.ts +19 -0
  59. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  60. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  61. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  62. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  63. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  64. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  65. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  66. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  67. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  68. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  69. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  70. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  71. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  72. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  73. package/src/wf-ui-server/a2a-store.mjs +296 -0
  74. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  75. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  76. package/src/wf-ui-server/runtime-config.mjs +153 -0
  77. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  78. package/src/wf-ui-server/security.mjs +67 -0
  79. package/src/wf-ui-server/server.mjs +849 -0
  80. package/src/wf-ui-server/session-registry.mjs +204 -0
  81. package/src/wf-ui-server/settings.mjs +100 -0
  82. package/src/wf-ui-server/task-parser.mjs +133 -0
  83. package/src/wf-ui-server/terminal-store.mjs +225 -0
  84. package/src/wf-ui-server/token.mjs +41 -0
  85. package/src/wf-ui-server/ws-events.mjs +354 -0
  86. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  87. package/templates/common/.claude/commands/wf-help.md +1 -0
  88. package/templates/common/.claude/commands/wf-task-list.md +2 -0
  89. package/templates/common/.claude/commands/wf-ui.md +26 -0
  90. package/templates/common/.claude/commands/wf-update.md +54 -9
  91. package/templates/common/.claude/rules/ecc/common.md +1 -1
  92. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  93. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  94. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  95. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  96. package/templates/common/.harness-version +63 -39
  97. package/templates/common/.opencode/commands/wf-help.md +1 -0
  98. package/templates/common/.opencode/commands/wf-task-list.md +2 -0
  99. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  100. package/templates/common/.opencode/commands/wf-update.md +54 -9
  101. package/templates/common/CLAUDE.md +1 -1
  102. package/templates/common/Harness/MEMORY.md +2 -0
  103. package/templates/common/Harness/README.md +6 -4
  104. package/templates/common/Harness/a2a/role-graph.json +79 -0
  105. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  106. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  107. package/templates/common/Harness/ownership.manifest.json +57 -2
  108. package/templates/common/Harness/scripts/README.md +134 -0
  109. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  110. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  111. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  112. package/templates/common/Harness/scripts/task-state.mjs +556 -23
  113. package/templates/common/Harness/scripts/validate-harness.mjs +229 -19
  114. package/templates/common/Harness/scripts/wf-remove.mjs +8 -3
  115. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  116. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  117. package/templates/common/Harness/settings.json +35 -0
  118. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +7 -2
  119. package/templates/common/Harness/specs/runtime/command-surface.json +14 -0
  120. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  121. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  122. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  123. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  124. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  125. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  126. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  127. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  128. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,503 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+ import { Bot, Check, ExternalLink, Plus, RefreshCw, Settings2, Square, Terminal } from 'lucide-react';
4
+ import { motion } from 'motion/react';
5
+ import { apiJson, apiJsonCached, invalidateApiCache } from '../api';
6
+ import { useReducedMotion } from '../hooks/useReducedMotion';
7
+ import type { RuntimeConfigFile, RuntimeInfo, Session } from '../types';
8
+ import LoadingView from './LoadingView';
9
+
10
+ type Props = { onSelectSession: (sessionId: string) => void };
11
+ type RuntimeConfig = { runtime: string; files: RuntimeConfigFile[] };
12
+ const SAVED_PAGE_SIZE = 5;
13
+
14
+ const PANEL: CSSProperties = {
15
+ border: '1px solid var(--border)',
16
+ borderRadius: 'var(--radius)',
17
+ background: 'var(--bg)',
18
+ };
19
+
20
+ function statusColor(status: string) {
21
+ if (status === 'running' || status === 'available') return '#166534';
22
+ if (status === 'blocked' || status === 'missing') return '#991b1b';
23
+ if (status === 'starting') return '#d97706';
24
+ return '#6b7280';
25
+ }
26
+
27
+ function compactPath(value: string | null | undefined) {
28
+ if (!value) return '';
29
+ const parts = value.split(/[\\/]/).filter(Boolean);
30
+ return parts.length > 4 ? `.../${parts.slice(-3).join('/')}` : value;
31
+ }
32
+
33
+ export default function AgentsRoute({ onSelectSession }: Props) {
34
+ const [sessions, setSessions] = useState<Session[]>([]);
35
+ const [savedSessions, setSavedSessions] = useState<Session[]>([]);
36
+ const [runtimes, setRuntimes] = useState<RuntimeInfo[]>([]);
37
+ const [launcherOpen, setLauncherOpen] = useState(false);
38
+ const [showSaved, setShowSaved] = useState(false);
39
+ const [savedPage, setSavedPage] = useState(0);
40
+ const [selectedRuntimeId, setSelectedRuntimeId] = useState('');
41
+ const [launchModel, setLaunchModel] = useState('');
42
+ const [creating, setCreating] = useState(false);
43
+ const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set());
44
+ const stoppingIdsRef = useRef<Set<string>>(new Set());
45
+ const [loading, setLoading] = useState(true);
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [configRuntimeId, setConfigRuntimeId] = useState<string | null>(null);
48
+ const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig | null>(null);
49
+ const [configValues, setConfigValues] = useState<Record<string, string>>({});
50
+ const [configScope, setConfigScope] = useState('user');
51
+ const [configSaved, setConfigSaved] = useState<string | null>(null);
52
+ const reducedMotion = useReducedMotion();
53
+
54
+ const selectedRuntime = useMemo(
55
+ () => runtimes.find(runtime => runtime.id === selectedRuntimeId) || null,
56
+ [runtimes, selectedRuntimeId],
57
+ );
58
+
59
+ const runningSessions = sessions;
60
+ const savedPageCount = Math.max(1, Math.ceil(savedSessions.length / SAVED_PAGE_SIZE));
61
+ const visibleSavedSessions = savedSessions.slice(
62
+ savedPage * SAVED_PAGE_SIZE,
63
+ savedPage * SAVED_PAGE_SIZE + SAVED_PAGE_SIZE,
64
+ );
65
+
66
+ const updateStoppingIds = (updater: (current: Set<string>) => Set<string>) => {
67
+ setStoppingIds(current => {
68
+ const next = updater(current);
69
+ stoppingIdsRef.current = next;
70
+ return next;
71
+ });
72
+ };
73
+
74
+ const loadControlPlane = async (refresh = false) => {
75
+ try {
76
+ if (refresh) invalidateApiCache('/api/runtimes');
77
+ const [runtimeRows, sessionRows, allRows] = await Promise.all([
78
+ apiJsonCached<RuntimeInfo[]>(refresh ? '/api/runtimes?refresh=1' : '/api/runtimes', { ttlMs: 12000, refresh }),
79
+ apiJson<Session[]>('/api/sessions'),
80
+ showSaved
81
+ ? apiJsonCached<Session[]>('/api/sessions?all=1', { ttlMs: refresh ? 0 : 3000, refresh }).catch(() => [])
82
+ : Promise.resolve([]),
83
+ ]);
84
+ const pendingStops = stoppingIdsRef.current;
85
+ setRuntimes(runtimeRows);
86
+ setSessions(sessionRows.filter(session => !pendingStops.has(session.sessionId)));
87
+ setSavedSessions(allRows.filter(session => session.status !== 'running' && session.status !== 'starting'));
88
+ updateStoppingIds(current => {
89
+ if (current.size === 0) return current;
90
+ const liveIds = new Set(sessionRows.map(session => session.sessionId));
91
+ return new Set([...current].filter(id => liveIds.has(id)));
92
+ });
93
+ setSelectedRuntimeId(current => current && runtimeRows.some(row => row.id === current)
94
+ ? current
95
+ : runtimeRows[0]?.id || '');
96
+ setError(null);
97
+ } catch (e: any) {
98
+ setError(e?.message || 'Failed to load agent terminals');
99
+ } finally {
100
+ setLoading(false);
101
+ }
102
+ };
103
+
104
+ useEffect(() => {
105
+ loadControlPlane();
106
+ const interval = setInterval(() => loadControlPlane(false), 1200);
107
+ const onSessionsChanged = (event: Event) => {
108
+ const detail = (event as CustomEvent<{ sessionId?: string; state?: string }>).detail;
109
+ if (detail?.sessionId && detail.state === 'stopping') {
110
+ updateStoppingIds(current => new Set(current).add(detail.sessionId!));
111
+ setSessions(current => current.filter(session => session.sessionId !== detail.sessionId));
112
+ return;
113
+ }
114
+ if (detail?.sessionId && detail.state === 'saved') {
115
+ updateStoppingIds(current => {
116
+ const next = new Set(current);
117
+ next.delete(detail.sessionId!);
118
+ return next;
119
+ });
120
+ setSessions(current => current.filter(session => session.sessionId !== detail.sessionId));
121
+ }
122
+ loadControlPlane(true);
123
+ };
124
+ window.addEventListener('harness:sessions-changed', onSessionsChanged);
125
+ return () => {
126
+ clearInterval(interval);
127
+ window.removeEventListener('harness:sessions-changed', onSessionsChanged);
128
+ };
129
+ }, [showSaved]);
130
+
131
+ useEffect(() => {
132
+ setSavedPage(page => Math.min(page, Math.max(0, savedPageCount - 1)));
133
+ }, [savedPageCount]);
134
+
135
+ const openConfig = async (runtimeId: string) => {
136
+ setConfigRuntimeId(runtimeId);
137
+ setRuntimeConfig(null);
138
+ setConfigValues({});
139
+ setConfigSaved(null);
140
+ try {
141
+ const data = await apiJson<RuntimeConfig>(`/api/runtimes/${encodeURIComponent(runtimeId)}/config`);
142
+ setRuntimeConfig(data);
143
+ const writable = data.files.find(file => file.writable) || data.files[0];
144
+ setConfigScope(writable?.scope || 'user');
145
+ setConfigValues({ ...(writable?.values || {}) });
146
+ } catch (e: any) {
147
+ setError(e?.message || 'Failed to load runtime config');
148
+ }
149
+ };
150
+
151
+ const selectedConfigFile = runtimeConfig?.files.find(file => file.scope === configScope && file.writable)
152
+ || runtimeConfig?.files.find(file => file.writable)
153
+ || null;
154
+
155
+ const switchConfigScope = (scope: string) => {
156
+ const nextFile = runtimeConfig?.files.find(file => file.scope === scope) || null;
157
+ setConfigScope(scope);
158
+ setConfigValues({ ...(nextFile?.values || {}) });
159
+ setConfigSaved(null);
160
+ };
161
+
162
+ const saveConfig = async () => {
163
+ if (!configRuntimeId || !selectedConfigFile) return;
164
+ setConfigSaved(null);
165
+ try {
166
+ const result = await apiJson<{ path: string; backupPath?: string | null }>(
167
+ `/api/runtimes/${encodeURIComponent(configRuntimeId)}/config`,
168
+ {
169
+ method: 'POST',
170
+ body: JSON.stringify({ scope: selectedConfigFile.scope, values: configValues }),
171
+ },
172
+ );
173
+ setConfigSaved(result.backupPath ? `Saved with backup: ${compactPath(result.backupPath)}` : `Saved: ${result.path}`);
174
+ await openConfig(configRuntimeId);
175
+ } catch (e: any) {
176
+ setError(e?.message || 'Failed to save runtime config');
177
+ }
178
+ };
179
+
180
+ const createSession = async (runtimeId = selectedRuntimeId) => {
181
+ if (!runtimeId) return;
182
+ setCreating(true);
183
+ setError(null);
184
+ try {
185
+ const session = await apiJson<Session>('/api/sessions', {
186
+ method: 'POST',
187
+ body: JSON.stringify({
188
+ runtime: runtimeId,
189
+ model: launchModel.trim(),
190
+ role: 'terminal-agent',
191
+ objective: 'Manual Harness agent terminal',
192
+ subagentMode: 'wf-subagents',
193
+ }),
194
+ });
195
+ invalidateApiCache('/api/sessions');
196
+ setLauncherOpen(false);
197
+ setLaunchModel('');
198
+ await loadControlPlane();
199
+ onSelectSession(session.sessionId);
200
+ } catch (e: any) {
201
+ setError(e?.message || 'Session launch failed');
202
+ } finally {
203
+ setCreating(false);
204
+ }
205
+ };
206
+
207
+ const stopSession = async (sessionId: string) => {
208
+ const previous = sessions;
209
+ updateStoppingIds(current => new Set(current).add(sessionId));
210
+ setSessions(current => current.filter(session => session.sessionId !== sessionId));
211
+ try {
212
+ await apiJson(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, { method: 'POST' });
213
+ invalidateApiCache('/api/sessions');
214
+ invalidateApiCache('/api/sessions?all=1');
215
+ window.dispatchEvent(new CustomEvent('harness:sessions-changed'));
216
+ await loadControlPlane();
217
+ } catch (e: any) {
218
+ setSessions(previous);
219
+ setError(e?.message || 'Stop failed');
220
+ } finally {
221
+ updateStoppingIds(current => {
222
+ const next = new Set(current);
223
+ next.delete(sessionId);
224
+ return next;
225
+ });
226
+ }
227
+ };
228
+
229
+ if (loading) {
230
+ return <LoadingView label="Loading agent terminals" />;
231
+ }
232
+
233
+ return (
234
+ <div style={{ display: 'grid', gap: 14 }}>
235
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
236
+ <h2 style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', margin: 0, display: 'flex', alignItems: 'center', gap: 5 }}>
237
+ <Bot size={14} /> Agent Terminals
238
+ </h2>
239
+ <div style={{ display: 'flex', gap: 6 }}>
240
+ <button onClick={() => { setShowSaved(value => !value); setSavedPage(0); }} title="Show saved terminal sessions" style={{ fontSize: 10, color: showSaved ? 'var(--fg)' : 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '4px 8px', background: showSaved ? 'var(--surface)' : 'var(--bg)' }}>
241
+ Saved
242
+ </button>
243
+ <button onClick={() => loadControlPlane(true)} title="Scan PATH again" style={{ fontSize: 10, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '4px 8px' }}>
244
+ <RefreshCw size={11} /> Scan
245
+ </button>
246
+ </div>
247
+ </div>
248
+
249
+ {error && (
250
+ <div style={{ fontSize: 11, color: '#991b1b', padding: '7px 9px', background: '#fef2f2', borderRadius: 'var(--radius)' }}>
251
+ {error}
252
+ <button onClick={() => setError(null)} style={{ marginLeft: 8, fontSize: 10, color: '#991b1b', textDecoration: 'underline' }}>dismiss</button>
253
+ </div>
254
+ )}
255
+
256
+ <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
257
+ <button
258
+ data-testid="agent-add-box"
259
+ onClick={() => setLauncherOpen(value => !value)}
260
+ style={{
261
+ ...PANEL,
262
+ minHeight: 132,
263
+ borderStyle: 'dashed',
264
+ display: 'grid',
265
+ placeItems: 'center',
266
+ textAlign: 'center',
267
+ padding: 16,
268
+ color: 'var(--fg)',
269
+ background: launcherOpen ? 'var(--surface)' : 'var(--bg)',
270
+ }}
271
+ >
272
+ <span style={{ width: 34, height: 34, borderRadius: 'var(--radius)', border: '1px solid var(--border)', display: 'grid', placeItems: 'center', marginBottom: 8 }}>
273
+ <Plus size={18} />
274
+ </span>
275
+ <span style={{ fontSize: 13, fontWeight: 600 }}>New Agent Terminal</span>
276
+ <span style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>Choose a detected terminal platform</span>
277
+ </button>
278
+
279
+ {runtimes.map(runtime => (
280
+ <motion.div
281
+ key={runtime.id}
282
+ data-testid="detected-runtime-card"
283
+ initial={reducedMotion ? undefined : { opacity: 0, y: 4 }}
284
+ animate={{ opacity: 1, y: 0 }}
285
+ style={{ ...PANEL, padding: 10, minHeight: 132, display: 'grid', alignContent: 'space-between', gap: 8 }}
286
+ >
287
+ <div style={{ minWidth: 0 }}>
288
+ <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
289
+ <span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor(runtime.status), flexShrink: 0 }} />
290
+ <span style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{runtime.label}</span>
291
+ <span style={{ fontSize: 9, color: 'var(--muted)', border: '1px solid var(--border)', borderRadius: 99, padding: '0 5px', flexShrink: 0 }}>{runtime.command}</span>
292
+ </div>
293
+ <div title={runtime.path || ''} style={{ fontSize: 10, color: 'var(--muted)', marginTop: 5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
294
+ {compactPath(runtime.path)}
295
+ </div>
296
+ <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
297
+ {runtime.version || runtime.capabilities.slice(0, 3).join(' / ')}
298
+ </div>
299
+ </div>
300
+ <div style={{ display: 'flex', gap: 5 }}>
301
+ <button onClick={() => createSession(runtime.id)} disabled={creating}
302
+ style={{ flex: 1, fontSize: 10, fontWeight: 600, color: '#fff', background: '#111', borderRadius: 'var(--radius)', padding: '5px 8px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, opacity: creating ? 0.5 : 1 }}>
303
+ <Terminal size={11} /> Start
304
+ </button>
305
+ <button onClick={() => openConfig(runtime.id)} title="Configure runtime" style={{ width: 28, height: 27, display: 'grid', placeItems: 'center', border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--muted)' }}>
306
+ <Settings2 size={12} />
307
+ </button>
308
+ </div>
309
+ </motion.div>
310
+ ))}
311
+ </section>
312
+
313
+ {launcherOpen && (
314
+ <section style={{ ...PANEL, padding: 12, display: 'grid', gap: 10 }}>
315
+ {runtimes.length === 0 ? (
316
+ <div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'center', padding: 16 }}>
317
+ No detected terminal agents. Run Scan after installing a CLI on PATH.
318
+ </div>
319
+ ) : (
320
+ <>
321
+ <div style={{ display: 'grid', gridTemplateColumns: 'minmax(160px, 240px) minmax(120px, 220px) auto', gap: 8, alignItems: 'end' }}>
322
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3 }}>
323
+ Platform
324
+ <select value={selectedRuntimeId} onChange={e => setSelectedRuntimeId(e.target.value)}
325
+ style={{ padding: '6px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
326
+ {runtimes.map(runtime => <option key={runtime.id} value={runtime.id}>{runtime.label}</option>)}
327
+ </select>
328
+ </label>
329
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3 }}>
330
+ Model Override
331
+ <input value={launchModel} onChange={e => setLaunchModel(e.target.value)} placeholder="optional"
332
+ style={{ padding: '6px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }} />
333
+ </label>
334
+ <button onClick={() => createSession()} disabled={!selectedRuntime || creating}
335
+ style={{ height: 31, padding: '0 14px', fontSize: 11, fontWeight: 600, borderRadius: 'var(--radius)', background: '#111', color: '#fff', opacity: !selectedRuntime || creating ? 0.5 : 1 }}>
336
+ Start Terminal
337
+ </button>
338
+ </div>
339
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>
340
+ {selectedRuntime ? `${selectedRuntime.command} from ${compactPath(selectedRuntime.path)}` : 'Only detected runtimes are listed.'}
341
+ </div>
342
+ </>
343
+ )}
344
+ </section>
345
+ )}
346
+
347
+ {configRuntimeId && (
348
+ <section style={{ ...PANEL, padding: 12 }}>
349
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
350
+ <div>
351
+ <div style={{ fontSize: 12, fontWeight: 600 }}>{runtimes.find(r => r.id === configRuntimeId)?.label || configRuntimeId} Config</div>
352
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>Known global/project config files. Authentication files are shown read-only unless a safe model field is known.</div>
353
+ </div>
354
+ <button onClick={() => setConfigRuntimeId(null)} style={{ fontSize: 10, color: 'var(--muted)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '3px 8px' }}>Close</button>
355
+ </div>
356
+
357
+ {!runtimeConfig ? (
358
+ <LoadingView label="Loading runtime config" />
359
+ ) : runtimeConfig.files.length === 0 ? (
360
+ <div style={{ fontSize: 11, color: 'var(--muted)' }}>No editable config path is known for this CLI yet.</div>
361
+ ) : (
362
+ <div style={{ display: 'grid', gridTemplateColumns: 'minmax(170px, 260px) minmax(0, 1fr)', gap: 12 }}>
363
+ <div style={{ display: 'grid', gap: 5 }}>
364
+ {runtimeConfig.files.map(file => (
365
+ <button key={`${file.scope}-${file.path}`} onClick={() => switchConfigScope(file.scope)}
366
+ style={{
367
+ textAlign: 'left',
368
+ border: `1px solid ${configScope === file.scope ? '#111' : 'var(--border)'}`,
369
+ borderRadius: 'var(--radius)',
370
+ padding: 8,
371
+ background: configScope === file.scope ? 'var(--surface)' : 'var(--bg)',
372
+ }}>
373
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 600 }}>
374
+ {file.exists && <Check size={11} color="#166534" />} {file.scope}
375
+ </div>
376
+ <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.path}</div>
377
+ </button>
378
+ ))}
379
+ </div>
380
+
381
+ <div>
382
+ {selectedConfigFile ? (
383
+ <>
384
+ <div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 8 }}>
385
+ {selectedConfigFile.format} / {selectedConfigFile.exists ? 'existing' : 'will create'} / {compactPath(selectedConfigFile.absolutePath)}
386
+ </div>
387
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
388
+ {Object.keys(selectedConfigFile.fields).map(alias => (
389
+ <label key={alias} style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3 }}>
390
+ {alias}
391
+ <input value={configValues[alias] || ''} onChange={e => setConfigValues(values => ({ ...values, [alias]: e.target.value }))}
392
+ style={{ padding: '6px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }} />
393
+ </label>
394
+ ))}
395
+ </div>
396
+ <button onClick={saveConfig} style={{ marginTop: 10, fontSize: 11, fontWeight: 600, color: '#fff', background: '#111', borderRadius: 'var(--radius)', padding: '6px 12px' }}>
397
+ Save Config
398
+ </button>
399
+ </>
400
+ ) : (
401
+ <div style={{ fontSize: 11, color: 'var(--muted)' }}>This config file is read-only in Harness UI.</div>
402
+ )}
403
+ {configSaved && <div style={{ fontSize: 10, color: '#166534', marginTop: 8 }}>{configSaved}</div>}
404
+ </div>
405
+ </div>
406
+ )}
407
+ </section>
408
+ )}
409
+
410
+ <section>
411
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>Running Terminals</div>
412
+ {runningSessions.length === 0 ? (
413
+ <div data-testid="agents-empty" style={{ padding: 28, color: 'var(--muted)', textAlign: 'center', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
414
+ <Terminal size={26} style={{ marginBottom: 8, opacity: 0.35 }} />
415
+ <p style={{ marginBottom: 3, fontWeight: 500 }}>No running agent terminal.</p>
416
+ <p>Use the plus box to start a detected CLI.</p>
417
+ </div>
418
+ ) : (
419
+ <div data-testid="agents-list" style={{ display: 'grid', gap: 5 }}>
420
+ {runningSessions.map(session => (
421
+ <SessionRow key={session.sessionId} session={session} onSelectSession={onSelectSession} onStop={stopSession} stopping={stoppingIds.has(session.sessionId)} />
422
+ ))}
423
+ </div>
424
+ )}
425
+ </section>
426
+
427
+ {showSaved && savedSessions.length > 0 && (
428
+ <section>
429
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
430
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--muted)', textTransform: 'uppercase' }}>
431
+ Saved Sessions ({savedSessions.length})
432
+ </div>
433
+ {savedSessions.length > SAVED_PAGE_SIZE && (
434
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: 'var(--muted)' }}>
435
+ <button onClick={() => setSavedPage(page => Math.max(0, page - 1))} disabled={savedPage === 0}
436
+ style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '2px 7px', opacity: savedPage === 0 ? 0.45 : 1 }}>
437
+ Prev
438
+ </button>
439
+ <span>{savedPage + 1} / {savedPageCount}</span>
440
+ <button onClick={() => setSavedPage(page => Math.min(savedPageCount - 1, page + 1))} disabled={savedPage >= savedPageCount - 1}
441
+ style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '2px 7px', opacity: savedPage >= savedPageCount - 1 ? 0.45 : 1 }}>
442
+ Next
443
+ </button>
444
+ </div>
445
+ )}
446
+ </div>
447
+ <div style={{ display: 'grid', gap: 5 }}>
448
+ {visibleSavedSessions.map(session => (
449
+ <SessionRow key={session.sessionId} session={session} onSelectSession={onSelectSession} onStop={stopSession} muted />
450
+ ))}
451
+ </div>
452
+ </section>
453
+ )}
454
+ </div>
455
+ );
456
+ }
457
+
458
+ function SessionRow({
459
+ session,
460
+ onSelectSession,
461
+ onStop,
462
+ muted = false,
463
+ stopping = false,
464
+ }: {
465
+ session: Session;
466
+ onSelectSession: (sessionId: string) => void;
467
+ onStop: (sessionId: string) => void;
468
+ muted?: boolean;
469
+ stopping?: boolean;
470
+ }) {
471
+ return (
472
+ <div data-testid="agent-row" style={{ ...PANEL, padding: 9, opacity: muted ? 0.68 : 1 }}>
473
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
474
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
475
+ <span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor(session.status), flexShrink: 0 }} />
476
+ <span style={{ fontSize: 12, fontWeight: 600 }}>{session.runtime}</span>
477
+ <span style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
478
+ {session.peerId || session.sessionId}
479
+ </span>
480
+ </div>
481
+ <div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
482
+ <button onClick={() => onSelectSession(session.sessionId)}
483
+ style={{ fontSize: 10, padding: '3px 8px', border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)', display: 'flex', alignItems: 'center', gap: 3 }}>
484
+ <ExternalLink size={9} /> Terminal
485
+ </button>
486
+ {!muted && (
487
+ <button onClick={() => onStop(session.sessionId)} disabled={stopping}
488
+ style={{ fontSize: 10, padding: '3px 8px', border: '1px solid #991b1b', borderRadius: 'var(--radius)', color: '#991b1b', background: 'var(--bg)', display: 'flex', alignItems: 'center', gap: 3 }}>
489
+ <Square size={9} /> {stopping ? 'Stopping' : 'Stop'}
490
+ </button>
491
+ )}
492
+ </div>
493
+ </div>
494
+ <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 4 }}>
495
+ {[stopping ? 'stopping' : session.status, session.role, session.workflowMode || null, session.ptyProvider || null, session.taskId || null, `${session.wsClientCount ?? 0} viewer(s)`].filter(Boolean).join(' / ')}
496
+ {session.model ? ` / ${session.model}` : ''}
497
+ {session.resumeCommand ? ` / resume: ${session.resumeCommand}` : ''}
498
+ {session.blockedReason ? ` / ${session.blockedReason}` : ''}
499
+ {session.blockedHint ? ` / ${session.blockedHint}` : ''}
500
+ </div>
501
+ </div>
502
+ );
503
+ }
@@ -0,0 +1,69 @@
1
+ import { motion } from 'motion/react';
2
+
3
+ type Props = {
4
+ connState: string;
5
+ eventSeq: number;
6
+ lastSync: string | null;
7
+ lastError: string | null;
8
+ };
9
+
10
+ export default function Footer({ connState, eventSeq, lastSync, lastError }: Props) {
11
+ const statusColor =
12
+ connState === 'connected' ? 'var(--success)' :
13
+ connState === 'degraded' || connState === 'reconnecting' ? 'var(--warn)' :
14
+ 'var(--danger)';
15
+
16
+ return (
17
+ <footer
18
+ data-testid="footer"
19
+ style={{
20
+ height: 'var(--footer-h)',
21
+ display: 'flex',
22
+ alignItems: 'center',
23
+ padding: '0 12px',
24
+ borderTop: '1px solid var(--border)',
25
+ background: 'var(--surface)',
26
+ fontSize: 10,
27
+ color: 'var(--muted)',
28
+ gap: 16,
29
+ flexShrink: 0,
30
+ }}
31
+ >
32
+ {/* Connection state */}
33
+ <span data-testid="footer-connection" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
34
+ <span
35
+ style={{
36
+ width: 6,
37
+ height: 6,
38
+ borderRadius: '50%',
39
+ background: statusColor,
40
+ display: 'inline-block',
41
+ }}
42
+ />
43
+ <span>{connState || 'disconnected'}</span>
44
+ </span>
45
+
46
+ {/* Event seq */}
47
+ <span data-testid="footer-event-seq">seq:{eventSeq}</span>
48
+
49
+ {/* Last sync */}
50
+ <span data-testid="footer-last-sync">
51
+ {lastSync ? `last sync:${lastSync}` : 'no sync'}
52
+ </span>
53
+
54
+ {/* Last error */}
55
+ {lastError && (
56
+ <span data-testid="footer-last-error" style={{ color: 'var(--danger)' }}>
57
+ error:{lastError}
58
+ </span>
59
+ )}
60
+
61
+ {/* Spacer */}
62
+ <span style={{ flex: 1 }} />
63
+
64
+ {/* Brand */}
65
+ <span data-testid="footer-brand">@bigonion</span>
66
+ <span data-testid="footer-license">MIT</span>
67
+ </footer>
68
+ );
69
+ }