create-harness-vibe-coding 0.8.17 → 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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,175 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import { Bot, CircleHelp, GitBranch, LayoutDashboard, Monitor, Settings, UsersRound } from 'lucide-react';
4
+ import { apiJsonCached } from '../api';
5
+
6
+ const NAV = [
7
+ { to: '/tasks', label: 'Tasks', icon: LayoutDashboard },
8
+ { to: '/workflow', label: 'Workflow', icon: GitBranch },
9
+ { to: '/agents', label: 'Agents', icon: Bot },
10
+ { to: '/roles', label: 'Roles', icon: UsersRound },
11
+ { to: '/settings', label: 'Settings', icon: Settings },
12
+ ];
13
+
14
+ type ProjectInfo = { root?: string; taskCount?: number };
15
+ type WorkflowInfo = { taskId?: string | null; phase?: string | null; gate?: string | null };
16
+
17
+ function basename(value: string | undefined) {
18
+ if (!value) return 'project';
19
+ return value.split(/[\\/]/).filter(Boolean).pop() || value;
20
+ }
21
+
22
+ export default function Header() {
23
+ const location = useLocation();
24
+ const [project, setProject] = useState<ProjectInfo | null>(null);
25
+ const [workflow, setWorkflow] = useState<WorkflowInfo | null>(null);
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ Promise.allSettled([
30
+ apiJsonCached<ProjectInfo>('/api/project', { ttlMs: 30000 }),
31
+ apiJsonCached<WorkflowInfo>('/api/workflow', { ttlMs: 2000 }),
32
+ ]).then(([projectResult, workflowResult]) => {
33
+ if (cancelled) return;
34
+ if (projectResult.status === 'fulfilled') setProject(projectResult.value);
35
+ if (workflowResult.status === 'fulfilled') setWorkflow(workflowResult.value);
36
+ });
37
+ return () => { cancelled = true; };
38
+ }, []);
39
+
40
+ const activeMeta = [workflow?.taskId, workflow?.phase, workflow?.gate].filter(Boolean).join(' / ');
41
+
42
+ return (
43
+ <header
44
+ data-testid="header"
45
+ style={{
46
+ height: 'var(--header-h)',
47
+ display: 'flex',
48
+ alignItems: 'center',
49
+ padding: '0 16px',
50
+ borderBottom: '1px solid var(--border)',
51
+ background: 'var(--bg)',
52
+ gap: 18,
53
+ flexShrink: 0,
54
+ minWidth: 0,
55
+ }}
56
+ >
57
+ <Link
58
+ to="/"
59
+ style={{
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ gap: 8,
63
+ fontWeight: 600,
64
+ fontSize: 14,
65
+ flexShrink: 0,
66
+ }}
67
+ title={project?.root || 'Harness'}
68
+ >
69
+ <div
70
+ data-testid="header-brand"
71
+ aria-hidden="true"
72
+ style={{
73
+ width: 22,
74
+ height: 22,
75
+ background: 'var(--fg)',
76
+ color: 'var(--bg)',
77
+ borderRadius: 4,
78
+ display: 'grid',
79
+ placeItems: 'center',
80
+ fontSize: 13,
81
+ lineHeight: 1,
82
+ fontWeight: 700,
83
+ }}
84
+ >
85
+ H
86
+ </div>
87
+ <span>Harness</span>
88
+ <span style={{ color: 'var(--muted)', fontSize: 11, fontWeight: 500 }}>
89
+ {basename(project?.root)}
90
+ </span>
91
+ </Link>
92
+
93
+ <nav data-testid="header-nav" style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
94
+ {NAV.map(({ to, label, icon: Icon }) => {
95
+ const active = location.pathname === to || (to === '/tasks' && location.pathname === '/');
96
+ return (
97
+ <Link
98
+ key={to}
99
+ to={to}
100
+ data-testid={`nav-${label.toLowerCase()}`}
101
+ style={{
102
+ display: 'flex',
103
+ alignItems: 'center',
104
+ gap: 6,
105
+ padding: '4px 10px',
106
+ borderRadius: 'var(--radius)',
107
+ fontSize: 12,
108
+ fontWeight: active ? 600 : 400,
109
+ color: active ? 'var(--fg)' : 'var(--muted)',
110
+ background: active ? 'var(--surface)' : 'transparent',
111
+ transition: 'background 0.15s',
112
+ whiteSpace: 'nowrap',
113
+ }}
114
+ >
115
+ <Icon size={14} />
116
+ {label}
117
+ </Link>
118
+ );
119
+ })}
120
+ </nav>
121
+
122
+ <div style={{ flex: 1, minWidth: 12 }} />
123
+
124
+ <div
125
+ data-testid="header-active-task"
126
+ style={{
127
+ fontSize: 11,
128
+ color: 'var(--muted)',
129
+ overflow: 'hidden',
130
+ textOverflow: 'ellipsis',
131
+ whiteSpace: 'nowrap',
132
+ maxWidth: 420,
133
+ }}
134
+ title={activeMeta || 'No active workflow'}
135
+ >
136
+ {activeMeta || `${project?.taskCount ?? 0} task(s)`}
137
+ </div>
138
+
139
+ <button
140
+ data-testid="theme-toggle"
141
+ title="System theme"
142
+ aria-label="System theme"
143
+ style={{
144
+ width: 28,
145
+ height: 28,
146
+ display: 'flex',
147
+ alignItems: 'center',
148
+ justifyContent: 'center',
149
+ borderRadius: 'var(--radius)',
150
+ border: '1px solid var(--border)',
151
+ color: 'var(--muted)',
152
+ flexShrink: 0,
153
+ }}
154
+ >
155
+ <Monitor size={14} />
156
+ </button>
157
+ <button
158
+ title="Harness help"
159
+ aria-label="Harness help"
160
+ style={{
161
+ width: 28,
162
+ height: 28,
163
+ display: 'grid',
164
+ placeItems: 'center',
165
+ borderRadius: 'var(--radius)',
166
+ border: '1px solid var(--border)',
167
+ color: 'var(--muted)',
168
+ flexShrink: 0,
169
+ }}
170
+ >
171
+ <CircleHelp size={14} />
172
+ </button>
173
+ </header>
174
+ );
175
+ }
@@ -0,0 +1,22 @@
1
+ type Props = {
2
+ label?: string;
3
+ fullCanvas?: boolean;
4
+ };
5
+
6
+ export default function LoadingView({ label = 'Loading', fullCanvas = false }: Props) {
7
+ return (
8
+ <div className={`loading-view${fullCanvas ? ' loading-view-full' : ''}`} role="status" aria-live="polite">
9
+ <div className="loading-card">
10
+ <span className="loading-spinner" aria-hidden="true" />
11
+ <div>
12
+ <div className="loading-label">{label}</div>
13
+ <div className="loading-lines" aria-hidden="true">
14
+ <span />
15
+ <span />
16
+ <span />
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,169 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Bot, GitBranch, RefreshCw, ShieldCheck, Terminal } from 'lucide-react';
3
+ import { apiJsonCached, invalidateApiCache } from '../api';
4
+ import LoadingView from './LoadingView';
5
+
6
+ type RoleNode = {
7
+ agentId: string;
8
+ role: string;
9
+ level: number;
10
+ kind: string;
11
+ skills?: string[];
12
+ permissions?: string[];
13
+ };
14
+
15
+ type RoleEdge = { from: string; to: string; relation: string };
16
+ type RoleGraph = { rootAgentId: string; agents: RoleNode[]; edges: RoleEdge[] };
17
+
18
+ function kindColor(kind: string) {
19
+ if (kind === 'controller') return '#111111';
20
+ if (kind === 'runtime') return '#1e40af';
21
+ if (kind === 'review' || kind === 'validation') return '#166534';
22
+ if (kind === 'implementer') return '#7c2d12';
23
+ return '#6b7280';
24
+ }
25
+
26
+ function NodeIcon({ kind }: { kind: string }) {
27
+ if (kind === 'runtime') return <Terminal size={14} />;
28
+ if (kind === 'validation' || kind === 'review') return <ShieldCheck size={14} />;
29
+ return <Bot size={14} />;
30
+ }
31
+
32
+ export default function RolesRoute() {
33
+ const [graph, setGraph] = useState<RoleGraph | null>(null);
34
+ const [selectedId, setSelectedId] = useState('ceo');
35
+ const [loading, setLoading] = useState(true);
36
+ const [error, setError] = useState<string | null>(null);
37
+
38
+ const loadRoles = async () => {
39
+ try {
40
+ const data = await apiJsonCached<RoleGraph>('/api/roles', { ttlMs: 30000 });
41
+ setGraph(data);
42
+ setSelectedId(current => current && data.agents.some(agent => agent.agentId === current) ? current : data.rootAgentId);
43
+ setError(null);
44
+ } catch (e: any) {
45
+ setError(e?.message || 'Failed to load roles');
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ };
50
+
51
+ useEffect(() => {
52
+ loadRoles();
53
+ }, []);
54
+
55
+ const lanes = useMemo(() => {
56
+ const grouped = new Map<number, RoleNode[]>();
57
+ for (const node of graph?.agents || []) {
58
+ const lane = grouped.get(node.level) || [];
59
+ lane.push(node);
60
+ grouped.set(node.level, lane);
61
+ }
62
+ return [...grouped.entries()].sort(([a], [b]) => a - b);
63
+ }, [graph]);
64
+
65
+ const selected = graph?.agents.find(node => node.agentId === selectedId) || null;
66
+ const inbound = graph?.edges.filter(edge => edge.to === selectedId) || [];
67
+ const outbound = graph?.edges.filter(edge => edge.from === selectedId) || [];
68
+
69
+ if (loading) return <LoadingView label="Loading roles" />;
70
+ if (!graph) return <div style={{ padding: 20, color: '#991b1b', fontSize: 11 }}>{error || 'No role graph available.'}</div>;
71
+
72
+ return (
73
+ <div style={{ display: 'grid', gap: 12 }}>
74
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
75
+ <h2 style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', margin: 0, display: 'flex', alignItems: 'center', gap: 5 }}>
76
+ <GitBranch size={14} /> Roles
77
+ </h2>
78
+ <button onClick={() => { invalidateApiCache('/api/roles'); loadRoles(); }} style={{ fontSize: 10, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '4px 8px' }}>
79
+ <RefreshCw size={11} /> Refresh
80
+ </button>
81
+ </div>
82
+
83
+ {error && <div style={{ fontSize: 11, color: '#991b1b' }}>{error}</div>}
84
+
85
+ <section style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(240px, 320px)', gap: 12, alignItems: 'start' }}>
86
+ <div style={{ display: 'grid', gap: 10 }}>
87
+ {lanes.map(([level, nodes]) => (
88
+ <div key={level} style={{ display: 'grid', gap: 6 }}>
89
+ <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', fontWeight: 700 }}>
90
+ {level === 0 ? 'Leader' : 'Role Layer'}
91
+ </div>
92
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: 6 }}>
93
+ {nodes.map(node => {
94
+ const active = selectedId === node.agentId;
95
+ const color = kindColor(node.kind);
96
+ return (
97
+ <button
98
+ key={node.agentId}
99
+ onClick={() => setSelectedId(node.agentId)}
100
+ style={{
101
+ border: `1px solid ${active ? color : 'var(--border)'}`,
102
+ borderLeft: `3px solid ${color}`,
103
+ borderRadius: 'var(--radius)',
104
+ padding: 9,
105
+ background: active ? 'var(--surface)' : 'var(--bg)',
106
+ textAlign: 'left',
107
+ minHeight: 82,
108
+ display: 'grid',
109
+ alignContent: 'start',
110
+ gap: 4,
111
+ }}
112
+ >
113
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
114
+ <NodeIcon kind={node.kind} />
115
+ <span style={{ fontSize: 12, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.role}</span>
116
+ </div>
117
+ <div style={{ fontSize: 10, color }}>{node.kind}</div>
118
+ <div style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
119
+ {(node.skills || []).slice(0, 3).join(' / ') || node.agentId}
120
+ </div>
121
+ </button>
122
+ );
123
+ })}
124
+ </div>
125
+ </div>
126
+ ))}
127
+ </div>
128
+
129
+ <aside style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 12, background: 'var(--bg)' }}>
130
+ {selected ? (
131
+ <>
132
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
133
+ <NodeIcon kind={selected.kind} />
134
+ <div style={{ minWidth: 0 }}>
135
+ <div style={{ fontSize: 12, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected.role}</div>
136
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>{selected.agentId}</div>
137
+ </div>
138
+ </div>
139
+
140
+ <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 5 }}>Edges</div>
141
+ <div style={{ display: 'grid', gap: 3, fontSize: 10, marginBottom: 10 }}>
142
+ {[...inbound.map(edge => `${edge.from} -> ${edge.relation}`), ...outbound.map(edge => `${edge.relation} -> ${edge.to}`)].map((line, index) => (
143
+ <div key={`${line}-${index}`} style={{ color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{line}</div>
144
+ ))}
145
+ {inbound.length + outbound.length === 0 && <div style={{ color: 'var(--muted)' }}>no edges</div>}
146
+ </div>
147
+
148
+ <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 5 }}>Skills</div>
149
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3, marginBottom: 10 }}>
150
+ {(selected.skills || []).map(skill => (
151
+ <span key={skill} style={{ fontSize: 9, padding: '1px 5px', border: '1px solid var(--border)', borderRadius: 99, color: 'var(--muted)' }}>{skill}</span>
152
+ ))}
153
+ </div>
154
+
155
+ <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 5 }}>Permissions</div>
156
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
157
+ {(selected.permissions || []).map(permission => (
158
+ <span key={permission} style={{ fontSize: 9, padding: '1px 5px', border: '1px solid var(--border)', borderRadius: 99, color: 'var(--muted)' }}>{permission}</span>
159
+ ))}
160
+ </div>
161
+ </>
162
+ ) : (
163
+ <div style={{ color: 'var(--muted)', fontSize: 11 }}>Select a role.</div>
164
+ )}
165
+ </aside>
166
+ </section>
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,166 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { RotateCcw, Save, Settings } from 'lucide-react';
4
+ import { apiJson, apiJsonCached, invalidateApiCache } from '../api';
5
+ import LoadingView from './LoadingView';
6
+
7
+ type SettingsData = {
8
+ server: { host: string; port: number };
9
+ terminal: { enabled: boolean; attachMode: boolean; defaultRuntime: string };
10
+ peers: { allowlist: string[] };
11
+ ui: { theme: string; language: string; reducedMotion: boolean };
12
+ };
13
+
14
+ const DEFAULTS: SettingsData = {
15
+ server: { host: '127.0.0.1', port: 0 },
16
+ terminal: { enabled: false, attachMode: false, defaultRuntime: 'codex' },
17
+ peers: {
18
+ allowlist: [
19
+ 'claude',
20
+ 'cc',
21
+ 'codex',
22
+ 'opencode',
23
+ 'gemini',
24
+ 'qwen',
25
+ 'deepseek',
26
+ 'pi',
27
+ 'aider',
28
+ 'goose',
29
+ 'amp',
30
+ 'crush',
31
+ 'cline',
32
+ 'plandex',
33
+ 'openclaw',
34
+ 'chatgpt',
35
+ 'openai',
36
+ ],
37
+ },
38
+ ui: { theme: 'auto', language: 'en', reducedMotion: false },
39
+ };
40
+
41
+ function mergeSettings(value: Partial<SettingsData>): SettingsData {
42
+ return {
43
+ ...DEFAULTS,
44
+ ...value,
45
+ server: { ...DEFAULTS.server, ...value.server },
46
+ terminal: { ...DEFAULTS.terminal, ...value.terminal },
47
+ peers: { ...DEFAULTS.peers, ...value.peers },
48
+ ui: { ...DEFAULTS.ui, ...value.ui },
49
+ };
50
+ }
51
+
52
+ export default function SettingsRoute() {
53
+ const [settings, setSettings] = useState<SettingsData>(DEFAULTS);
54
+ const [loading, setLoading] = useState(true);
55
+ const [saved, setSaved] = useState(false);
56
+ const [error, setError] = useState<string | null>(null);
57
+
58
+ const loadSettings = async () => {
59
+ try {
60
+ const data = await apiJsonCached<Partial<SettingsData>>('/api/settings', { ttlMs: 10000 });
61
+ setSettings(mergeSettings(data));
62
+ setError(null);
63
+ } catch {
64
+ setError('Failed to load settings');
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ };
69
+
70
+ useEffect(() => {
71
+ loadSettings();
72
+ }, []);
73
+
74
+ const save = async () => {
75
+ setError(null);
76
+ setSaved(false);
77
+ try {
78
+ await apiJson('/api/settings', {
79
+ method: 'POST',
80
+ body: JSON.stringify(settings),
81
+ });
82
+ invalidateApiCache('/api/settings');
83
+ setSaved(true);
84
+ setTimeout(() => setSaved(false), 2000);
85
+ } catch (e: any) {
86
+ setError(e?.message || 'Save failed');
87
+ }
88
+ };
89
+
90
+ const field = (label: string, child: ReactNode) => (
91
+ <div style={{ marginBottom: 12 }}>
92
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--muted)', marginBottom: 4, textTransform: 'uppercase' }}>{label}</div>
93
+ {child}
94
+ </div>
95
+ );
96
+
97
+ if (loading) return <LoadingView label="Loading settings" />;
98
+
99
+ return (
100
+ <div style={{ maxWidth: 560 }}>
101
+ <h2 style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
102
+ <Settings size={14} /> Settings
103
+ </h2>
104
+
105
+ <div style={{ padding: 14, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
106
+ {field('Server Host', <input type="text" value={settings.server.host} onChange={e => setSettings(s => ({ ...s, server: { ...s.server, host: e.target.value } }))} style={{ width: '100%', padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }} />)}
107
+
108
+ {field('Server Port', <input type="number" value={settings.server.port} onChange={e => setSettings(s => ({ ...s, server: { ...s.server, port: parseInt(e.target.value) || 0 } }))} style={{ width: 100, padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }} />)}
109
+
110
+ {field('UI Theme', (
111
+ <select value={settings.ui.theme} onChange={e => setSettings(s => ({ ...s, ui: { ...s.ui, theme: e.target.value } }))} style={{ padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
112
+ <option value="auto">auto</option>
113
+ <option value="light">light</option>
114
+ <option value="dark">dark</option>
115
+ </select>
116
+ ))}
117
+
118
+ {field('Language', (
119
+ <select value={settings.ui.language} onChange={e => setSettings(s => ({ ...s, ui: { ...s.ui, language: e.target.value } }))} style={{ padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
120
+ <option value="en">English</option>
121
+ <option value="zh">Chinese</option>
122
+ <option value="ja">Japanese</option>
123
+ </select>
124
+ ))}
125
+
126
+ {field('Terminal', (
127
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
128
+ <input type="checkbox" checked={settings.terminal.enabled} onChange={e => setSettings(s => ({ ...s, terminal: { ...s.terminal, enabled: e.target.checked } }))} />
129
+ Enable PTY terminal
130
+ </label>
131
+ ))}
132
+
133
+ {field('Default Task Agent', (
134
+ <select value={settings.terminal.defaultRuntime} onChange={e => setSettings(s => ({ ...s, terminal: { ...s.terminal, defaultRuntime: e.target.value } }))} style={{ padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
135
+ {settings.peers.allowlist.map(runtime => <option key={runtime} value={runtime}>{runtime}</option>)}
136
+ </select>
137
+ ))}
138
+
139
+ {field('Reduced Motion', (
140
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
141
+ <input type="checkbox" checked={settings.ui.reducedMotion} onChange={e => setSettings(s => ({ ...s, ui: { ...s.ui, reducedMotion: e.target.checked } }))} />
142
+ Disable animations
143
+ </label>
144
+ ))}
145
+
146
+ {field('Peer Runtimes', (
147
+ <input type="text" value={settings.peers.allowlist.join(', ')}
148
+ onChange={e => setSettings(s => ({ ...s, peers: { allowlist: e.target.value.split(',').map(x => x.trim()).filter(Boolean) } }))}
149
+ style={{ width: '100%', padding: '4px 8px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)' }} />
150
+ ))}
151
+ </div>
152
+
153
+ {error && <div style={{ marginTop: 8, fontSize: 11, color: '#991b1b', padding: '6px 8px', background: '#fef2f2', borderRadius: 'var(--radius)' }}>{error}</div>}
154
+ {saved && <div style={{ marginTop: 8, fontSize: 11, color: '#166534', padding: '6px 8px', background: '#dcfce7', borderRadius: 'var(--radius)' }}>Settings saved.</div>}
155
+
156
+ <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
157
+ <button onClick={save} style={{ padding: '6px 16px', fontSize: 11, fontWeight: 500, border: '1px solid #166534', borderRadius: 'var(--radius)', background: '#166534', color: '#fff', display: 'flex', alignItems: 'center', gap: 4 }}>
158
+ <Save size={12} /> Save
159
+ </button>
160
+ <button onClick={loadSettings} style={{ padding: '6px 16px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)', display: 'flex', alignItems: 'center', gap: 4 }}>
161
+ <RotateCcw size={12} /> Reload
162
+ </button>
163
+ </div>
164
+ </div>
165
+ );
166
+ }